chore: script setup components (#7299)

This commit is contained in:
Kuchenpirat
2026-03-23 21:18:25 +01:00
committed by GitHub
parent 3ad2d9155d
commit 5ab6e98f9e
47 changed files with 1721 additions and 2453 deletions

View File

@@ -37,73 +37,68 @@
</v-container>
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const { $globals } = useNuxtApp();
<script setup lang="ts">
const i18n = useI18n();
const auth = useMealieAuth();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const { $globals } = useNuxtApp();
const sections = ref([
const sections = ref([
{
title: i18n.t("profile.data-migrations"),
color: "info",
links: [
{
title: i18n.t("profile.data-migrations"),
color: "info",
links: [
{
icon: $globals.icons.backupRestore,
to: "/admin/backups",
text: i18n.t("settings.backup.backup-restore"),
description: i18n.t("admin.setup.restore-from-v1-backup"),
},
{
icon: $globals.icons.import,
to: "/group/migrations",
text: i18n.t("migration.recipe-migration"),
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
},
],
icon: $globals.icons.backupRestore,
to: "/admin/backups",
text: i18n.t("settings.backup.backup-restore"),
description: i18n.t("admin.setup.restore-from-v1-backup"),
},
{
title: i18n.t("recipe.create-recipes"),
color: "success",
links: [
{
icon: $globals.icons.createAlt,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
text: i18n.t("recipe.create-recipe"),
description: i18n.t("recipe.create-recipe-description"),
},
{
icon: $globals.icons.link,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
text: i18n.t("recipe.import-with-url"),
description: i18n.t("recipe.scrape-recipe-description"),
},
],
icon: $globals.icons.import,
to: "/group/migrations",
text: i18n.t("migration.recipe-migration"),
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
},
{
title: i18n.t("user.manage-users"),
color: "primary",
links: [
{
icon: $globals.icons.group,
to: "/admin/manage/users",
text: i18n.t("user.manage-users"),
description: i18n.t("user.manage-users-description"),
},
{
icon: $globals.icons.user,
to: "/user/profile",
text: i18n.t("profile.manage-user-profile"),
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
},
],
},
]);
return { sections };
],
},
});
{
title: i18n.t("recipe.create-recipes"),
color: "success",
links: [
{
icon: $globals.icons.createAlt,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
text: i18n.t("recipe.create-recipe"),
description: i18n.t("recipe.create-recipe-description"),
},
{
icon: $globals.icons.link,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
text: i18n.t("recipe.import-with-url"),
description: i18n.t("recipe.scrape-recipe-description"),
},
],
},
{
title: i18n.t("user.manage-users"),
color: "primary",
links: [
{
icon: $globals.icons.group,
to: "/admin/manage/users",
text: i18n.t("user.manage-users"),
description: i18n.t("user.manage-users-description"),
},
{
icon: $globals.icons.user,
to: "/user/profile",
text: i18n.t("profile.manage-user-profile"),
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
},
],
},
]);
</script>
<style>

View File

@@ -25,48 +25,32 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
export default defineNuxtComponent({
components: { RecipeCardSection, RecipeExplorerPageSearch },
setup() {
const auth = useMealieAuth();
const route = useRoute();
const auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const ready = ref(false);
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
const ready = ref(false);
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
const searchQuery = computed(() => {
return searchComponent.value?.passedQueryWithSeed || {};
});
function onSearchReady() {
ready.value = true;
}
function onItemSelected(item: any, urlPrefix: string) {
searchComponent.value?.filterItems(item, urlPrefix);
}
return {
ready,
searchComponent,
searchQuery,
recipes,
appendRecipes,
replaceRecipes,
onSearchReady,
onItemSelected,
};
},
const searchQuery = computed(() => {
return searchComponent.value?.passedQueryWithSeed || {};
});
function onSearchReady() {
ready.value = true;
}
function onItemSelected(item: any, urlPrefix: string) {
searchComponent.value?.filterItems(item, urlPrefix);
}
</script>

View File

@@ -7,7 +7,7 @@
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: menuProps }">
<v-badge
v-memo="[selectedCount]"
:model-value="selectedCount > 0"
@@ -19,7 +19,7 @@
size="small"
color="accent"
dark
v-bind="props"
v-bind="menuProps"
>
<slot />
</v-btn>
@@ -145,89 +145,72 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ISearchableItem } from "~/composables/use-search";
import { useSearch } from "~/composables/use-search";
export default defineNuxtComponent({
props: {
items: {
type: Array as () => ISearchableItem[],
required: true,
},
modelValue: {
type: Array as () => any[],
required: true,
},
requireAll: {
type: Boolean,
default: undefined,
},
radio: {
type: Boolean,
default: false,
},
const props = defineProps({
items: {
type: Array as () => ISearchableItem[],
required: true,
},
emits: ["update:requireAll", "update:modelValue"],
setup(props, context) {
const state = reactive({
menu: false,
});
// Use the search composable
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
const combinator = computed({
get: () => (props.requireAll ? "hasAll" : "hasAny"),
set: (value) => {
context.emit("update:requireAll", value === "hasAll");
},
});
// Use shallowRef to prevent deep reactivity on large arrays
const selected = computed({
get: () => props.modelValue as ISearchableItem[],
set: (value) => {
context.emit("update:modelValue", value);
},
});
const selectedRadio = computed({
get: () => (selected.value.length > 0 ? selected.value[0] : null),
set: (value) => {
context.emit("update:modelValue", value ? [value] : []);
},
});
const selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => {
return new Set(selected.value.map(item => item.id));
});
const handleRadioClick = (item: ISearchableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
};
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
searchInput.value = "";
}
return {
combinator,
state,
searchInput,
selected,
selectedRadio,
selectedCount,
selectedIds,
filtered,
handleRadioClick,
clearSelection,
};
requireAll: {
type: Boolean,
default: undefined,
},
radio: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel<ISearchableItem[]>();
const emit = defineEmits<{
(e: "update:requireAll", value: boolean | undefined): void;
}>();
const state = reactive({
menu: false,
});
// Use the search composable
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
const combinator = computed({
get: () => (props.requireAll ? "hasAll" : "hasAny"),
set: (value: string) => {
emit("update:requireAll", value === "hasAll");
},
});
const selected = computed<ISearchableItem[]>({
get: () => modelValue.value ?? [],
set: (value: ISearchableItem[]) => {
modelValue.value = value;
},
});
const selectedRadio = computed<null | ISearchableItem>({
get: () => (selected.value.length > 0 ? selected.value[0] : null),
set: (value: ISearchableItem | null) => {
const next = value ? [value] : [];
selected.value = next;
},
});
const selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => new Set(selected.value.map(item => item.id)));
const handleRadioClick = (item: ISearchableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
};
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
searchInput.value = "";
}
</script>

View File

@@ -12,23 +12,13 @@
</v-chip>
</template>
<script lang="ts">
<script setup lang="ts">
import { getTextColor } from "~/composables/use-text-color";
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
props: {
label: {
type: Object as () => MultiPurposeLabelSummary,
required: true,
},
},
setup(props) {
const textColor = computed(() => getTextColor(props.label.color));
const props = defineProps<{
label: MultiPurposeLabelSummary;
}>();
return {
textColor,
};
},
});
const textColor = computed(() => getTextColor(props.label.color));
</script>

View File

@@ -17,13 +17,13 @@
start
min-width="125px"
>
<template #activator="{ props }">
<template #activator="{ props: hoverProps }">
<v-btn
size="small"
variant="text"
class="ml-2 handle"
icon
v-bind="props"
v-bind="hoverProps"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
@@ -35,31 +35,13 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object as () => ShoppingListMultiPurposeLabelOut,
required: true,
},
useColor: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const labelColor = ref<string | undefined>(props.useColor ? props.modelValue.label.color : undefined);
const props = defineProps<{
useColor?: boolean;
}>();
const modelValue = defineModel<ShoppingListMultiPurposeLabelOut>({ required: true });
function contextHandler(event: string) {
context.emit(event);
}
return {
contextHandler,
labelColor,
};
},
});
const labelColor = ref<string | undefined>(props.useColor ? modelValue.value.label.color : undefined);
</script>

View File

@@ -10,15 +10,12 @@
<v-col :cols="itemLabelCols">
<div class="d-flex align-center flex-nowrap">
<v-checkbox
v-model="listItem.checked"
:model-value="listItem.checked"
hide-details
density="compact"
class="mt-0 flex-shrink-0"
color="null"
@click="() => {
listItem.checked = !listItem.checked
$emit('checked', listItem)
}"
@click="toggleChecked"
/>
<div
class="ml-2 text-truncate"
@@ -43,7 +40,7 @@
start
min-width="125px"
>
<template #activator="{ props }">
<template #activator="{ props: hoverProps }">
<v-tooltip
v-if="recipeList && recipeList.length"
open-delay="200"
@@ -84,7 +81,7 @@
variant="text"
class="handle"
icon
v-bind="props"
v-bind="hoverProps"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
@@ -155,158 +152,99 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useOnline } from "@vueuse/core";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
interface actions {
text: string;
event: string;
const model = defineModel<ShoppingListItemOut>({ type: Object as () => ShoppingListItemOut, required: true });
const props = defineProps({
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
units: {
type: Array as () => IngredientUnit[],
required: true,
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
recipes: {
type: Map as unknown as () => Map<string, RecipeSummary>,
default: undefined,
},
});
const emit = defineEmits<{
(e: "checked" | "save", item: ShoppingListItemOut): void;
(e: "delete"): void;
}>();
const i18n = useI18n();
const displayRecipeRefs = ref(false);
const itemLabelCols = computed<string>(() => (model.value?.checked ? "auto" : "6"));
const online = useOnline();
const isOffline = computed(() => online.value === false);
type actions = { text: string; event: string };
const contextMenu = ref<actions[]>([
{ text: i18n.t("general.edit") as string, event: "edit" },
{ text: i18n.t("general.delete") as string, event: "delete" },
]);
// copy prop value so a refresh doesn't interrupt the user
const localListItem = ref(Object.assign({}, model.value));
const listItem = computed<ShoppingListItemOut>({
get: () => model.value,
set: (val: ShoppingListItemOut) => {
localListItem.value = val;
model.value = val;
},
});
const edit = ref(false);
function toggleEdit(val = !edit.value) {
if (edit.value === val) return;
if (val) localListItem.value = model.value;
edit.value = val;
}
export default defineNuxtComponent({
components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
props: {
modelValue: {
type: Object as () => ShoppingListItemOut,
required: true,
},
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
units: {
type: Array as () => IngredientUnit[],
required: true,
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
recipes: {
type: Map<string, RecipeSummary>,
default: undefined,
},
},
emits: ["checked", "update:modelValue", "save", "delete"],
setup(props, context) {
const i18n = useI18n();
const displayRecipeRefs = ref(false);
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
const isOffline = computed(() => useOnline().value === false);
function toggleChecked() {
const updated = { ...model.value, checked: !model.value.checked } as ShoppingListItemOut;
model.value = updated;
emit("checked", updated);
}
const contextMenu: actions[] = [
{
text: i18n.t("general.edit") as string,
event: "edit",
},
{
text: i18n.t("general.delete") as string,
event: "delete",
},
];
function contextHandler(event: string) {
if (event === "edit") {
toggleEdit(true);
}
else {
emit(event as any);
}
}
// copy prop value so a refresh doesn't interrupt the user
const localListItem = ref(Object.assign({}, props.modelValue));
const listItem = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
// keep local copy in sync
localListItem.value = val;
context.emit("update:modelValue", val);
},
});
const edit = ref(false);
function toggleEdit(val = !edit.value) {
if (edit.value === val) {
return;
}
function save() {
emit("save", localListItem.value);
edit.value = false;
}
if (val) {
// update local copy of item with the current value
localListItem.value = props.modelValue;
}
edit.value = val;
}
function contextHandler(event: string) {
if (event === "edit") {
toggleEdit(true);
}
else {
context.emit(event);
}
}
function save() {
context.emit("save", localListItem.value);
edit.value = false;
}
const updatedLabels = computed(() => {
return props.labels.map((label) => {
return {
id: label.id,
text: label.name,
};
});
});
/**
* Gets the label for the shopping list item. Either the label assign to the item
* or the label of the food applied.
*/
const label = computed<MultiPurposeLabelSummary | undefined>(() => {
if (listItem.value.label) {
return listItem.value.label as MultiPurposeLabelSummary;
}
if (listItem.value.food?.label) {
return listItem.value.food.label;
}
return undefined;
});
const recipeList = computed<RecipeSummary[]>(() => {
const recipeList: RecipeSummary[] = [];
if (!listItem.value.recipeReferences) {
return recipeList;
}
listItem.value.recipeReferences.forEach((ref) => {
const recipe = props.recipes?.get(ref.recipeId);
if (recipe) {
recipeList.push(recipe);
}
});
return recipeList;
});
return {
updatedLabels,
save,
contextHandler,
displayRecipeRefs,
edit,
contextMenu,
itemLabelCols,
listItem,
localListItem,
label,
recipeList,
toggleEdit,
isOffline,
};
},
const recipeList = computed<RecipeSummary[]>(() => {
const ret: RecipeSummary[] = [];
if (!listItem.value.recipeReferences) return ret;
listItem.value.recipeReferences.forEach((ref) => {
const recipe = props.recipes?.get(ref.recipeId);
if (recipe) ret.push(recipe);
});
return ret;
});
</script>

View File

@@ -102,125 +102,108 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
required: true,
},
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
units: {
type: Array as () => IngredientUnit[],
required: true,
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
allowDelete: {
type: Boolean,
required: false,
default: true,
},
// modelValue as reactive v-model
const listItem = defineModel<ShoppingListItemCreate | ShoppingListItemOut>({ required: true });
defineProps({
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
emits: ["update:modelValue", "save", "cancel", "delete"],
setup(props, context) {
const foodStore = useFoodStore();
const foodData = useFoodData();
const unitStore = useUnitStore();
const unitData = useUnitData();
const listItem = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
watch(
() => props.modelValue.quantity,
() => {
if (!props.modelValue.quantity) {
listItem.value.quantity = 0;
}
},
);
watch(
() => props.modelValue.food,
(newFood) => {
listItem.value.label = newFood?.label || null;
listItem.value.labelId = listItem.value.label?.id || null;
},
);
const autoFocus = !listItem.value.food && listItem.value.note ? "note" : "food";
async function createAssignFood(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val });
foodData.data.name = val;
const newFood = await foodStore.actions.createOne(foodData.data);
if (newFood) {
listItem.value.food = newFood;
listItem.value.foodId = newFood.id;
}
foodData.reset();
}
async function createAssignUnit(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val });
unitData.data.name = val;
const newUnit = await unitStore.actions.createOne(unitData.data);
if (newUnit) {
listItem.value.unit = newUnit;
listItem.value.unitId = newUnit.id;
}
unitData.reset();
}
async function assignLabelToFood() {
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
return;
}
listItem.value.food.labelId = listItem.value.labelId;
await foodStore.actions.updateOne(listItem.value.food);
}
return {
listItem,
autoFocus,
createAssignFood,
createAssignUnit,
assignLabelToFood,
};
units: {
type: Array as () => IngredientUnit[],
required: true,
},
methods: {
handleNoteKeyPress(event) {
// Save on Enter
if (!event.shiftKey && event.key === "Enter") {
event.preventDefault();
this.$emit("save");
}
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
allowDelete: {
type: Boolean,
required: false,
default: true,
},
});
// const emit = defineEmits<["save", "cancel", "delete"]>();
const emit = defineEmits<{
(e: "save", item: ShoppingListItemOut): void;
(e: "cancel" | "delete"): void;
}>();
const foodStore = useFoodStore();
const foodData = useFoodData();
const unitStore = useUnitStore();
const unitData = useUnitData();
watch(
() => listItem.value.quantity,
(newQty) => {
if (!newQty) {
listItem.value.quantity = 0;
}
},
);
watch(
() => listItem.value.food,
(newFood) => {
listItem.value.label = newFood?.label || null;
listItem.value.labelId = listItem.value.label?.id || null;
},
);
const autoFocus = computed(() => (!listItem.value.food && listItem.value.note ? "note" : "food"));
async function createAssignFood(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val } as any);
foodData.data.name = val;
const newFood = await foodStore.actions.createOne(foodData.data);
if (newFood) {
listItem.value.food = newFood;
listItem.value.foodId = newFood.id;
}
foodData.reset();
}
async function createAssignUnit(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val } as any);
unitData.data.name = val;
const newUnit = await unitStore.actions.createOne(unitData.data);
if (newUnit) {
listItem.value.unit = newUnit;
listItem.value.unitId = newUnit.id;
}
unitData.reset();
}
async function assignLabelToFood() {
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
return;
}
listItem.value.food.labelId = listItem.value.labelId;
await foodStore.actions.updateOne(listItem.value.food);
}
function handleNoteKeyPress(event: KeyboardEvent) {
const e = event as KeyboardEvent & { key: string; shiftKey: boolean };
if (!e.shiftKey && e.key === "Enter") {
e.preventDefault();
emit("save");
}
}
</script>

View File

@@ -4,10 +4,10 @@
:disabled="!user || !tooltip"
location="end"
>
<template #activator="{ props }">
<template #activator="{ props: tooltipProps }">
<v-avatar
v-if="list"
v-bind="props"
v-bind="tooltipProps"
>
<v-img
:src="imageURL"
@@ -19,7 +19,7 @@
<v-avatar
v-else
:size="size"
v-bind="props"
v-bind="tooltipProps"
>
<v-img
:src="imageURL"
@@ -35,51 +35,40 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserStore } from "~/composables/store/use-user-store";
export default defineNuxtComponent({
props: {
userId: {
type: String,
required: true,
},
list: {
type: Boolean,
default: false,
},
size: {
type: String,
default: "42",
},
tooltip: {
type: Boolean,
default: true,
},
const props = defineProps({
userId: {
type: String,
required: true,
},
setup(props) {
const state = reactive({
error: false,
});
const auth = useMealieAuth();
const { store: users } = useUserStore();
const user = computed(() => {
return users.value.find(user => user.id === props.userId);
});
const imageURL = computed(() => {
// Note: auth.user is a ref now
const authUser = auth.user.value;
const key = authUser?.cacheKey ?? "";
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
});
return {
user,
imageURL,
...toRefs(state),
};
list: {
type: Boolean,
default: false,
},
size: {
type: String,
default: "42",
},
tooltip: {
type: Boolean,
default: true,
},
});
const error = ref(false);
const auth = useMealieAuth();
const { store: users } = useUserStore();
const user = computed(() => {
return users.value.find(user => user.id === props.userId);
});
const imageURL = computed(() => {
// Note: auth.user is a ref now
const authUser = auth.user.value;
const key = authUser?.cacheKey ?? "";
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
});
</script>

View File

@@ -73,8 +73,7 @@
</BaseDialog>
</template>
<script lang="ts">
import { watchEffect } from "vue";
<script setup lang="ts">
import { useUserApi } from "@/composables/api";
import BaseDialog from "~/components/global/BaseDialog.vue";
import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
@@ -86,147 +85,95 @@ import type { HouseholdInDB } from "~/lib/api/types/household";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
export default defineNuxtComponent({
name: "UserInviteDialog",
components: {
BaseDialog,
AppButtonCopy,
BaseButton,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const auth = useMealieAuth();
const inviteDialog = defineModel<boolean>("modelValue", { type: Boolean, default: false });
const isAdmin = computed(() => auth.user.value?.admin);
const token = ref("");
const selectedGroup = ref<string | null>(null);
const selectedHousehold = ref<string | null>(null);
const groups = ref<GroupInDB[]>([]);
const households = ref<HouseholdInDB[]>([]);
const api = useUserApi();
const i18n = useI18n();
const auth = useMealieAuth();
const fetchGroupsAndHouseholds = () => {
if (isAdmin.value) {
const groupsResponse = useGroups();
const householdsResponse = useAdminHouseholds();
watchEffect(() => {
groups.value = groupsResponse.groups.value || [];
households.value = householdsResponse.households.value || [];
});
}
};
const isAdmin = computed(() => auth.user.value?.admin);
const token = ref("");
const selectedGroup = ref<string | null>(null);
const selectedHousehold = ref<string | null>(null);
const groups = ref<GroupInDB[]>([]);
const households = ref<HouseholdInDB[]>([]);
const api = useUserApi();
const inviteDialog = computed<boolean>({
get() {
return props.modelValue;
},
set(val) {
context.emit("update:modelValue", val);
},
const fetchGroupsAndHouseholds = () => {
if (isAdmin.value) {
const groupsResponse = useGroups();
const householdsResponse = useAdminHouseholds();
watchEffect(() => {
groups.value = groupsResponse.groups.value || [];
households.value = householdsResponse.households.value || [];
});
}
};
async function getSignupLink(group: string | null = null, household: string | null = null) {
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
const { data } = await api.households.createInvitation(payload);
if (data) {
token.value = data.token;
}
}
async function getSignupLink(group: string | null = null, household: string | null = null) {
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
const { data } = await api.households.createInvitation(payload);
if (data) {
token.value = data.token;
}
}
const filteredHouseholds = computed(() => {
if (!selectedGroup.value) return [];
return households.value?.filter(household => household.groupId === selectedGroup.value);
});
function constructLink(token: string) {
return token ? `${window.location.origin}/register?token=${token}` : "";
}
const generatedSignupLink = computed(() => {
return constructLink(token.value);
});
// =================================================
// Email Invitation
const state = reactive({
loading: false,
sendTo: "",
});
async function sendInvite() {
state.loading = true;
if (!token.value) {
getSignupLink(selectedGroup.value, selectedHousehold.value);
}
const { data } = await api.email.sendInvitation({
email: state.sendTo,
token: token.value,
});
if (data && data.success) {
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
state.loading = false;
inviteDialog.value = false;
}
const validEmail = computed(() => {
if (state.sendTo === "") {
return false;
}
const valid = validators.email(state.sendTo);
// Explicit bool check because validators.email sometimes returns a string
if (valid === true) {
return true;
}
return false;
});
return {
sendInvite,
validators,
validEmail,
inviteDialog,
getSignupLink,
generatedSignupLink,
selectedGroup,
selectedHousehold,
filteredHouseholds,
groups,
households,
fetchGroupsAndHouseholds,
...toRefs(state),
isAdmin,
};
},
watch: {
modelValue: {
immediate: false,
handler(val) {
if (val && !this.isAdmin) {
this.getSignupLink();
}
},
},
selectedHousehold(newVal) {
if (newVal && this.selectedGroup) {
this.getSignupLink(this.selectedGroup, this.selectedHousehold);
}
},
},
created() {
this.fetchGroupsAndHouseholds();
},
const filteredHouseholds = computed(() => {
if (!selectedGroup.value) return [];
return households.value?.filter(household => household.groupId === selectedGroup.value);
});
function constructLink(tokenVal: string) {
return tokenVal ? `${window.location.origin}/register?token=${tokenVal}` : "";
}
const generatedSignupLink = computed(() => constructLink(token.value));
// Email Invitation
const state = reactive({
loading: false,
sendTo: "",
});
const { loading, sendTo } = toRefs(state);
async function sendInvite() {
state.loading = true;
if (!token.value) {
getSignupLink(selectedGroup.value, selectedHousehold.value);
}
const { data } = await api.email.sendInvitation({
email: state.sendTo,
token: token.value,
});
if (data && data.success) {
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
state.loading = false;
inviteDialog.value = false;
}
const validEmail = computed(() => {
if (sendTo.value === "") return false;
const valid = validators.email(sendTo.value);
return valid === true;
});
// Watchers (replacing options API watchers)
watch(inviteDialog, (val) => {
if (val && !isAdmin.value) {
getSignupLink();
}
});
watch(selectedHousehold, (newVal) => {
if (newVal && selectedGroup.value) {
getSignupLink(selectedGroup.value, selectedHousehold.value);
}
});
// initial fetch
fetchGroupsAndHouseholds();
</script>

View File

@@ -52,14 +52,14 @@
</v-card>
</template>
<script lang="ts" setup>
<script setup lang="ts">
interface LinkProp {
text: string;
url?: string;
to: string;
}
const props = defineProps({
defineProps({
link: {
type: Object as () => LinkProp,
required: true,
@@ -70,6 +70,4 @@ const props = defineProps({
default: "",
},
});
console.log("Props", props);
</script>

View File

@@ -78,57 +78,29 @@
</div>
</template>
<script lang="ts">
import { useDark } from "@vueuse/core";
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
import { usePasswordField } from "~/composables/use-passwords";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
definePageMeta({ layout: "blank" });
const inputAttrs = {
validateOnBlur: true,
class: "pb-1",
variant: "solo-filled" as any,
};
export default defineNuxtComponent({
components: { UserPasswordStrength },
setup() {
definePageMeta({
layout: "blank",
});
const isDark = useDark();
const langDialog = ref(false);
const pwFields = usePasswordField();
const {
accountDetails,
credentials,
emailErrorMessages,
usernameErrorMessages,
validateUsername,
validateEmail,
domAccountForm,
} = useUserRegistrationForm();
return {
accountDetails,
credentials,
emailErrorMessages,
inputAttrs,
isDark,
langDialog,
pwFields,
usernameErrorMessages,
validators,
// Validators
validateUsername,
validateEmail,
// Dom Refs
domAccountForm,
};
},
});
const pwFields = usePasswordField();
const {
accountDetails,
credentials,
emailErrorMessages,
usernameErrorMessages,
validateUsername,
validateEmail,
} = useUserRegistrationForm();
</script>
<style lang="css" scoped>

View File

@@ -94,210 +94,195 @@
</v-app>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SideBarLink } from "~/types/application-types";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay();
const auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const i18n = useI18n();
const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay();
const auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const cookbookPreferences = useCookbookPreferences();
const ownCookbookStore = useCookbookStore(i18n);
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
const cookbookPreferences = useCookbookPreferences();
const ownCookbookStore = useCookbookStore(i18n);
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
function getPublicCookbookStore(slug: string) {
if (!publicCookbookStoreCache.value[slug]) {
publicCookbookStoreCache.value[slug] = usePublicCookbookStore(slug, i18n);
}
return publicCookbookStoreCache.value[slug];
}
function getPublicCookbookStore(slug: string) {
if (!publicCookbookStoreCache.value[slug]) {
publicCookbookStoreCache.value[slug] = usePublicCookbookStore(slug, i18n);
}
return publicCookbookStoreCache.value[slug];
}
const cookbooks = computed(() => {
if (isOwnGroup.value) {
return ownCookbookStore.store.value;
}
else if (groupSlug.value) {
const publicStore = getPublicCookbookStore(groupSlug.value);
return unref(publicStore.store);
}
return [];
});
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean>(false);
onMounted(() => {
sidebar.value = display.lgAndUp.value;
});
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
return {
key: cookbook.slug || "",
icon: $globals.icons.pages,
title: cookbook.name,
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
restricted: false,
};
}
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value?.length) {
return [];
}
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
const ownLinks: SideBarLink[] = [];
const links: SideBarLink[] = [];
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
const householdName = cookbook.household?.name || "";
(acc[householdName] ||= []).push(cookbook);
return acc;
}, {} as Record<string, ReadCookBook[]>);
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
if (!cookbooks.length) {
return;
}
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
ownLinks.push(...cookbooks.map(cookbookAsLink));
}
else {
links.push({
key: householdName,
icon: $globals.icons.book,
title: householdName,
children: cookbooks.map(cookbookAsLink),
restricted: false,
});
}
});
links.sort((a, b) => a.title.localeCompare(b.title));
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
return ownLinks;
}
else {
return [...ownLinks, ...links];
}
});
const createLinks = computed(() => [
{
insertDivider: false,
icon: $globals.icons.link,
title: i18n.t("general.import"),
subtitle: i18n.t("new-recipe.import-by-url"),
to: `/g/${groupSlug.value}/r/create/url`,
restricted: true,
hide: false,
},
{
insertDivider: false,
icon: $globals.icons.fileImage,
title: i18n.t("recipe.create-from-images"),
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`,
restricted: true,
hide: !showImageImport.value,
},
{
insertDivider: true,
icon: $globals.icons.edit,
title: i18n.t("general.create"),
subtitle: i18n.t("new-recipe.create-manually"),
to: `/g/${groupSlug.value}/r/create/new`,
restricted: true,
hide: false,
},
]);
const topLinks = computed<SideBarLink[]>(() => [
{
icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`,
title: i18n.t("general.recipes"),
restricted: false,
},
{
icon: $globals.icons.search,
to: `/g/${groupSlug.value}/recipes/finder`,
title: i18n.t("recipe-finder.recipe-finder"),
restricted: false,
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.t("meal-plan.meal-planner"),
to: "/household/mealplan/planner/view",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.t("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.t("recipe.timeline"),
to: `/g/${groupSlug.value}/recipes/timeline`,
restricted: true,
},
{
icon: $globals.icons.book,
to: `/g/${groupSlug.value}/cookbooks`,
title: i18n.t("cookbook.cookbooks"),
restricted: true,
},
{
icon: $globals.icons.organizers,
title: i18n.t("general.organizers"),
restricted: true,
children: [
{
icon: $globals.icons.categories,
to: `/g/${groupSlug.value}/recipes/categories`,
title: i18n.t("sidebar.categories"),
restricted: true,
},
{
icon: $globals.icons.tags,
to: `/g/${groupSlug.value}/recipes/tags`,
title: i18n.t("sidebar.tags"),
restricted: true,
},
{
icon: $globals.icons.potSteam,
to: `/g/${groupSlug.value}/recipes/tools`,
title: i18n.t("tool.tools"),
restricted: true,
},
],
},
]);
return {
groupSlug,
cookbookLinks,
createLinks,
topLinks,
isOwnGroup,
languageDialog,
sidebar,
};
},
const cookbooks = computed(() => {
if (isOwnGroup.value) {
return ownCookbookStore.store.value;
}
else if (groupSlug.value) {
const publicStore = getPublicCookbookStore(groupSlug.value);
return unref(publicStore.store);
}
return [];
});
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const sidebar = ref<boolean>(false);
onMounted(() => {
sidebar.value = display.lgAndUp.value;
});
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
return {
key: cookbook.slug || "",
icon: $globals.icons.pages,
title: cookbook.name,
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
restricted: false,
};
}
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value?.length) {
return [];
}
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
const ownLinks: SideBarLink[] = [];
const links: SideBarLink[] = [];
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
const householdName = cookbook.household?.name || "";
(acc[householdName] ||= []).push(cookbook);
return acc;
}, {} as Record<string, ReadCookBook[]>);
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
if (!cookbooks.length) {
return;
}
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
ownLinks.push(...cookbooks.map(cookbookAsLink));
}
else {
links.push({
key: householdName,
icon: $globals.icons.book,
title: householdName,
children: cookbooks.map(cookbookAsLink),
restricted: false,
});
}
});
links.sort((a, b) => a.title.localeCompare(b.title));
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
return ownLinks;
}
else {
return [...ownLinks, ...links];
}
});
const createLinks = computed(() => [
{
insertDivider: false,
icon: $globals.icons.link,
title: i18n.t("general.import"),
subtitle: i18n.t("new-recipe.import-by-url"),
to: `/g/${groupSlug.value}/r/create/url`,
restricted: true,
hide: false,
},
{
insertDivider: false,
icon: $globals.icons.fileImage,
title: i18n.t("recipe.create-from-images"),
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`,
restricted: true,
hide: !showImageImport.value,
},
{
insertDivider: true,
icon: $globals.icons.edit,
title: i18n.t("general.create"),
subtitle: i18n.t("new-recipe.create-manually"),
to: `/g/${groupSlug.value}/r/create/new`,
restricted: true,
hide: false,
},
]);
const topLinks = computed<SideBarLink[]>(() => [
{
icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`,
title: i18n.t("general.recipes"),
restricted: false,
},
{
icon: $globals.icons.search,
to: `/g/${groupSlug.value}/recipes/finder`,
title: i18n.t("recipe-finder.recipe-finder"),
restricted: false,
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.t("meal-plan.meal-planner"),
to: "/household/mealplan/planner/view",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.t("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.t("recipe.timeline"),
to: `/g/${groupSlug.value}/recipes/timeline`,
restricted: true,
},
{
icon: $globals.icons.book,
to: `/g/${groupSlug.value}/cookbooks`,
title: i18n.t("cookbook.cookbooks"),
restricted: true,
},
{
icon: $globals.icons.organizers,
title: i18n.t("general.organizers"),
restricted: true,
children: [
{
icon: $globals.icons.categories,
to: `/g/${groupSlug.value}/recipes/categories`,
title: i18n.t("sidebar.categories"),
restricted: true,
},
{
icon: $globals.icons.tags,
to: `/g/${groupSlug.value}/recipes/tags`,
title: i18n.t("sidebar.tags"),
restricted: true,
},
{
icon: $globals.icons.potSteam,
to: `/g/${groupSlug.value}/recipes/tools`,
title: i18n.t("tool.tools"),
restricted: true,
},
],
},
]);
</script>

View File

@@ -29,11 +29,3 @@
</v-row>
</v-footer>
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
return {};
},
});
</script>

View File

@@ -8,14 +8,14 @@
class="d-print-none"
>
<slot />
<router-link :to="routerLink">
<RouterLink :to="routerLink">
<v-btn
icon
color="white"
>
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
</v-btn>
</router-link>
</RouterLink>
<div
btn
@@ -84,67 +84,53 @@
</v-app-bar>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
import type RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
export default defineNuxtComponent({
components: { RecipeDialogSearch },
props: {
menu: {
type: Boolean,
default: true,
},
},
setup() {
const auth = useMealieAuth();
const { loggedIn } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { xs, smAndUp } = useDisplay();
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
function activateSearch() {
domSearchDialog.value?.open();
}
function handleKeyEvent(e: KeyboardEvent) {
const activeTag = document.activeElement?.tagName;
if (e.key === "/" && activeTag !== "INPUT" && activeTag !== "TEXTAREA") {
e.preventDefault();
activateSearch();
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeyEvent);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyEvent);
});
async function logout() {
try {
await auth.signOut("/login?direct=1");
}
catch (e) {
console.error(e);
}
}
return {
activateSearch,
domSearchDialog,
routerLink,
loggedIn,
logout,
xs, smAndUp,
};
defineProps({
menu: {
type: Boolean,
default: true,
},
});
const auth = useMealieAuth();
const { loggedIn } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { xs, smAndUp } = useDisplay();
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
function activateSearch() {
domSearchDialog.value?.open();
}
function handleKeyEvent(e: KeyboardEvent) {
const activeTag = document.activeElement?.tagName;
if (e.key === "/" && activeTag !== "INPUT" && activeTag !== "TEXTAREA") {
e.preventDefault();
activateSearch();
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeyEvent);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyEvent);
});
async function logout() {
try {
await auth.signOut("/login?direct=1");
}
catch (e) {
console.error(e);
}
}
</script>
<style scoped>

View File

@@ -1,8 +1,8 @@
<template>
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed" touchless>
<LanguageDialog v-model="languageDialog" />
<v-navigation-drawer v-model="modelValue" class="d-flex flex-column d-print-none position-fixed" touchless>
<LanguageDialog v-model="state.languageDialog" />
<!-- User Profile -->
<template v-if="loggedIn">
<template v-if="loggedIn && sessionUser">
<v-list-item lines="two" :to="userProfileLink" exact>
<div class="d-flex align-center ga-2">
<UserAvatar list :user-id="sessionUser.id" :tooltip="false" />
@@ -29,20 +29,20 @@
<!-- Primary Links -->
<template v-if="topLink">
<v-list v-model:selected="secondarySelected" nav density="comfortable" color="primary">
<v-list v-model:selected="state.secondarySelected" nav density="comfortable" color="primary">
<template v-for="nav in topLink">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
v-model="state.dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
:fluid="true"
>
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
<template #activator="{ props: hoverProps }">
<v-list-item v-bind="hoverProps" :prepend-icon="nav.icon" :title="nav.title" />
</template>
<v-list-item
@@ -75,20 +75,20 @@
<!-- Secondary Links -->
<template v-if="secondaryLinks.length > 0">
<v-divider class="mt-2" />
<v-list v-model:selected="secondarySelected" nav density="compact" exact>
<v-list v-model:selected="state.secondarySelected" nav density="compact" exact>
<template v-for="nav in secondaryLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
v-model="state.dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
fluid
>
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
<template #activator="{ props: hoverProps }">
<v-list-item v-bind="hoverProps" :prepend-icon="nav.icon" :title="nav.title" />
</template>
<v-list-item
@@ -116,13 +116,13 @@
<!-- Bottom Navigation Links -->
<template #append>
<v-list v-model:selected="bottomSelected" nav density="comfortable">
<v-list v-model:selected="state.bottomSelected" nav density="comfortable">
<v-menu location="end bottom" :offset="15">
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
<template #activator="{ props: hoverProps }">
<v-list-item v-bind="hoverProps" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
</template>
<v-list density="comfortable" color="primary">
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="languageDialog=true" />
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="state.languageDialog=true" />
<v-list-item :prepend-icon="$vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight" :title="$vuetify.theme.current.dark ? $t('settings.theme.light-mode') : $t('settings.theme.dark-mode')" @click="toggleDark" />
<v-divider v-if="loggedIn" class="my-2" />
<v-list-item v-if="loggedIn" :prepend-icon="$globals.icons.cog" :title="$t('profile.user-settings')" to="/user/profile" />
@@ -136,92 +136,63 @@
</v-navigation-drawer>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useToggleDarkMode } from "~/composables/use-utils";
export default defineNuxtComponent({
components: {
UserAvatar,
const props = defineProps({
user: {
type: Object,
default: null,
},
props: {
modelValue: {
type: Boolean,
required: false,
default: false,
},
user: {
type: Object,
default: null,
},
topLink: {
type: Array as () => SidebarLinks,
required: true,
},
secondaryLinks: {
type: Array as () => SidebarLinks,
required: false,
default: null,
},
topLink: {
type: Array as () => SidebarLinks,
required: true,
},
emits: ["update:modelValue"],
setup(props, context) {
const auth = useMealieAuth();
const { loggedIn, isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => auth.user.value?.admin);
const canManage = computed(() => auth.user.value?.canManage);
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
const toggleDark = useToggleDarkMode();
const state = reactive({
dropDowns: {} as Record<string, boolean>,
topSelected: null as string[] | null,
secondarySelected: null as string[] | null,
bottomSelected: null as string[] | null,
hasOpenedBefore: false as boolean,
languageDialog: false as boolean,
});
// model to control the drawer
const showDrawer = computed({
get: () => props.modelValue,
set: value => context.emit("update:modelValue", value),
});
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
function initDropdowns() {
allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false;
});
}
watch(
() => allLinks,
() => {
initDropdowns();
},
{
deep: true,
},
);
return {
...toRefs(state),
userFavoritesLink,
userProfileLink,
showDrawer,
loggedIn,
isAdmin,
canManage,
isOwnGroup,
sessionUser: auth.user,
toggleDark,
};
secondaryLinks: {
type: Array as () => SidebarLinks,
required: false,
default: null,
},
});
const modelValue = defineModel<boolean>({ default: false });
const auth = useMealieAuth();
const sessionUser = computed(() => auth.user.value);
const { loggedIn, isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => auth.user.value?.admin);
const canManage = computed(() => auth.user.value?.canManage);
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
const toggleDark = useToggleDarkMode();
const state = reactive({
dropDowns: {} as Record<string, boolean>,
secondarySelected: null as string[] | null,
bottomSelected: null as string[] | null,
languageDialog: false as boolean,
});
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
function initDropdowns() {
allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false;
});
}
watch(
() => allLinks,
() => {
initDropdowns();
},
{
deep: true,
},
);
</script>
<style scoped>

View File

@@ -49,27 +49,21 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useNuxtApp } from "#app";
import { toastAlert, toastLoading } from "~/composables/use-toast";
export default {
setup() {
const { $globals } = useNuxtApp();
const icon = computed(() => {
switch (toastAlert.color) {
case "error":
return $globals.icons.alertOutline;
case "success":
return $globals.icons.checkBold;
case "info":
return $globals.icons.informationOutline;
default:
return $globals.icons.alertOutline;
}
});
return { icon, toastAlert, toastLoading };
},
};
const { $globals } = useNuxtApp();
const icon = computed(() => {
switch (toastAlert.color) {
case "error":
return $globals.icons.alertOutline;
case "success":
return $globals.icons.checkBold;
case "info":
return $globals.icons.informationOutline;
default:
return $globals.icons.alertOutline;
}
});
</script>

View File

@@ -1,21 +1,13 @@
<template>
<div scoped-slot />
<slot v-if="advanced" />
</template>
<script lang="ts">
<script setup lang="ts">
/**
* Renderless component that only renders if the user is logged in.
* and has advanced options toggled.
*/
export default defineNuxtComponent({
setup(_, ctx) {
const auth = useMealieAuth();
const auth = useMealieAuth();
const r = auth.user.value?.advanced || false;
return () => {
return r ? ctx.slots.default?.() : null;
};
},
});
const advanced = auth.user.value?.advanced || false;
</script>

View File

@@ -8,7 +8,7 @@
close-delay="500"
transition="slide-y-transition"
>
<template #activator="{ props }">
<template #activator="{ props: hoverProps }">
<v-btn
variant="flat"
:icon="icon"
@@ -16,7 +16,7 @@
retain-focus-on-click
:class="btnClass"
:disabled="copyText !== '' ? false : true"
v-bind="props"
v-bind="hoverProps"
@click="textToClipboard()"
>
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
@@ -33,66 +33,53 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
export default defineNuxtComponent({
props: {
copyText: {
type: String,
required: true,
},
color: {
type: String,
default: "",
},
icon: {
type: Boolean,
default: true,
},
btnClass: {
type: String,
default: "",
},
const props = defineProps({
copyText: {
type: String,
required: true,
},
setup(props) {
const { copy, copied, isSupported } = useClipboard();
const show = ref(false);
const copyToolTip = ref<VTooltip | null>(null);
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);
}
return {
show,
copyToolTip,
textToClipboard,
copied,
isSupported,
copiedSuccess,
};
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

@@ -1,5 +1,5 @@
<template>
<v-form ref="files">
<v-form ref="form">
<input
ref="uploader"
class="d-none"
@@ -26,144 +26,129 @@
</v-form>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
const UPLOAD_EVENT = "uploaded";
export default defineNuxtComponent({
props: {
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 props = defineProps({
small: {
type: Boolean,
default: false,
},
setup(props, context) {
const files = 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 (files.value.length === 0) {
return;
}
isSelecting.value = true;
if (!props.post) {
// NOTE: To preserve behaviour for other parents of this component,
// we emit a single File if !props.multiple.
context.emit(UPLOAD_EVENT, props.multiple ? files.value : files.value[0]);
isSelecting.value = false;
return;
}
// WARN: My change is only for !props.post.
// I have not added support for multiple files in the API.
// Existing call-sites never passed the `multiple` prop,
// so this case will only be hit if the prop is set to true.
if (props.multiple && files.value.length > 1) {
console.warn("Multiple file uploads are not supported by the API.");
return;
}
const file = files.value[0];
const formData = new FormData();
formData.append(props.fileName, file);
try {
const response = await api.upload.file(props.url, formData);
if (response) {
context.emit(UPLOAD_EVENT, response);
}
}
catch (e) {
console.error(e);
context.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) {
files.value = Array.from(target.files);
upload();
}
}
function onButtonClick() {
isSelecting.value = true;
window.addEventListener(
"focus",
() => {
isSelecting.value = false;
},
{ once: true },
);
uploader.value?.click();
}
return {
files,
uploader,
isSelecting,
effIcon,
defaultText,
onFileChanged,
onButtonClick,
};
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

@@ -39,71 +39,63 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
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,
},
<script setup lang="ts">
const props = defineProps({
loading: {
type: Boolean,
default: true,
},
setup(props) {
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;
return {
size,
waitingTextCalculated,
};
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

@@ -18,13 +18,11 @@
</v-toolbar>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
back: {
type: Boolean,
default: false,
},
<script setup lang="ts">
defineProps({
back: {
type: Boolean,
default: false,
},
});
</script>

View File

@@ -15,14 +15,12 @@
</BannerWarning>
</template>
<script lang="ts">
export default {
props: {
issue: {
type: String,
required: false,
default: "",
},
<script setup lang="ts">
defineProps({
issue: {
type: String,
required: false,
default: "",
},
};
});
</script>

View File

@@ -1,7 +1,6 @@
<template>
<v-alert
border="start"
border-color
variant="tonal"
type="warning"
elevation="2"
@@ -20,19 +19,17 @@
</v-alert>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: false,
default: "",
},
description: {
type: String,
required: false,
default: "",
},
<script setup lang="ts">
defineProps({
title: {
type: String,
required: false,
default: "",
},
};
description: {
type: String,
required: false,
default: "",
},
});
</script>

View File

@@ -32,191 +32,168 @@
</v-btn>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
export default defineNuxtComponent({
name: "BaseButton",
props: {
// Types
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
download: {
type: Boolean,
default: false,
},
downloadUrl: {
type: String,
default: "",
},
// Property
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
// Styles
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 props = defineProps({
cancel: {
type: Boolean,
default: false,
},
setup(props) {
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;
}
else if (props.update) {
return buttonOptions.update;
}
else if (props.edit) {
return buttonOptions.edit;
}
else if (props.cancel) {
return buttonOptions.cancel;
}
else if (props.save) {
return buttonOptions.save;
}
else 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;
}
else if (props.minor || props.cancel) {
return buttonStyles.minor;
}
return buttonStyles.defaults;
});
const api = useUserApi();
function downloadFile() {
api.utils.download(props.downloadUrl);
}
return {
btnAttrs,
btnStyle,
downloadFile,
};
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

@@ -10,13 +10,13 @@
start
:style="stretch ? 'width: 100%;' : ''"
>
<template #activator="{ props }">
<template #activator="{ props: hoverProps }">
<v-btn
tile
:large="large"
icon
variant="plain"
v-bind="props"
v-bind="hoverProps"
>
<v-icon>
{{ btn.icon }}
@@ -51,7 +51,7 @@
location="bottom"
content-class="text-caption"
>
<template #activator="{ props }">
<template #activator="{ props: tooltipProps }">
<v-btn
tile
icon
@@ -60,7 +60,7 @@
:disabled="btn.disabled"
:style="stretch ? `width: ${maxButtonWidth};` : ''"
variant="plain"
v-bind="props"
v-bind="tooltipProps"
@click="$emit(btn.event)"
>
<v-icon> {{ btn.icon }} </v-icon>
@@ -72,7 +72,7 @@
</v-item-group>
</template>
<script lang="ts">
<script setup lang="ts">
export interface ButtonOption {
icon?: string;
color?: string;
@@ -83,26 +83,20 @@ export interface ButtonOption {
divider?: boolean;
}
export default defineNuxtComponent({
props: {
buttons: {
type: Array as () => ButtonOption[],
required: true,
},
large: {
type: Boolean,
default: true,
},
stretch: {
type: Boolean,
default: false,
},
const props = defineProps({
buttons: {
type: Array as () => ButtonOption[],
required: true,
},
setup(props) {
const maxButtonWidth = computed(() => `${100 / props.buttons.length}%`);
return {
maxButtonWidth,
};
large: {
type: Boolean,
default: true,
},
stretch: {
type: Boolean,
default: false,
},
});
const maxButtonWidth = computed(() => `${100 / props.buttons.length}%`);
</script>

View File

@@ -29,21 +29,19 @@
</v-card>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
title: {
type: String,
required: true,
},
icon: {
type: String,
default: "",
},
section: {
type: Boolean,
default: false,
},
<script setup lang="ts">
defineProps({
title: {
type: String,
required: true,
},
icon: {
type: String,
default: "",
},
section: {
type: Boolean,
default: false,
},
});
</script>

View File

@@ -6,21 +6,19 @@
/>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
width: {
type: String,
default: "100px",
},
thickness: {
type: String,
default: "2px",
},
color: {
type: String,
default: "accent",
},
<script setup lang="ts">
defineProps({
width: {
type: String,
default: "100px",
},
thickness: {
type: String,
default: "2px",
},
color: {
type: String,
default: "accent",
},
});
</script>

View File

@@ -1,9 +1,9 @@
<template>
<v-menu offset-y>
<template #activator="{ props }">
<template #activator="{ props: hoverProps }">
<v-btn
color="primary"
v-bind="{ ...props, ...$attrs }"
v-bind="{ ...hoverProps, ...$attrs }"
:class="btnClass"
:disabled="disabled"
>
@@ -105,7 +105,7 @@
</v-menu>
</template>
<script lang="ts">
<script setup lang="ts">
const MODES = {
model: "model",
link: "link",
@@ -124,67 +124,57 @@ export interface MenuItem {
hide?: boolean;
}
export default defineNuxtComponent({
props: {
mode: {
type: String as () => modes,
default: "model",
},
items: {
type: Array as () => MenuItem[],
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
modelValue: {
type: String,
required: false,
default: "",
},
btnClass: {
type: String,
required: false,
default: "",
},
btnText: {
type: String,
required: false,
default: function () {
return useI18n().t("general.actions");
},
},
const props = defineProps({
mode: {
type: String as () => modes,
default: "model",
},
emits: ["update:modelValue"],
setup(props, context) {
const activeObj = ref<MenuItem>({
text: "DEFAULT",
value: "",
});
let startIndex = 0;
props.items.forEach((item, index) => {
if (item.value === props.modelValue) {
startIndex = index;
activeObj.value = item;
}
});
const itemGroup = ref(startIndex);
function setValue(v: MenuItem) {
context.emit("update:modelValue", v.value);
activeObj.value = v;
}
return {
MODES,
activeObj,
itemGroup,
setValue,
};
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

@@ -22,13 +22,11 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
divider: {
type: Boolean,
default: false,
},
<script setup lang="ts">
defineProps({
divider: {
type: Boolean,
default: false,
},
});
</script>

View File

@@ -1,124 +0,0 @@
<template>
<v-card
v-bind="$attrs"
:class="classes"
class="v-card--material pa-3"
>
<div class="d-flex grow flex-wrap">
<slot name="avatar">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon
v-if="icon"
size="40"
>
{{ icon }}
</v-icon>
<div
v-if="text"
class="headline font-weight-thin"
v-text="text"
/>
</v-sheet>
</slot>
<div
v-if="$slots['after-heading']"
class="ml-auto"
>
<slot name="after-heading" />
</div>
</div>
<slot />
<template v-if="$slots.actions">
<v-divider class="mt-2" />
<v-card-actions class="pb-0">
<slot name="actions" />
</v-card-actions>
</template>
<template v-if="$slots.bottom">
<v-divider
v-if="!$slots.actions"
class="mt-2"
/>
<div class="pb-0">
<slot name="bottom" />
</div>
</template>
</v-card>
</template>
<script lang="ts">
export default defineNuxtComponent({
name: "MaterialCard",
props: {
avatar: {
type: String,
default: "",
},
color: {
type: String,
default: "primary",
},
icon: {
type: String,
default: undefined,
},
image: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
},
setup() {
const display = useDisplay();
const hasHeading = computed(() => false);
const hasAltHeading = computed(() => false);
const classes = computed(() => {
return {
"v-card--material--has-heading": hasHeading,
"mt-3": display.name.value === "xs" || display.name.value === "sm",
};
});
return {
hasHeading,
hasAltHeading,
classes,
};
},
});
</script>
<style lang="sass">
.v-card--material
&__avatar
position: relative
top: -64px
margin-bottom: -32px
&__heading
position: relative
top: -40px
transition: .3s ease
z-index: 1
</style>

View File

@@ -16,21 +16,19 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
to: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
icon: {
type: String,
default: "",
},
<script setup lang="ts">
defineProps({
to: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
icon: {
type: String,
default: "",
},
});
</script>

View File

@@ -40,27 +40,25 @@
</v-menu>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ContextMenuItem } from "~/composables/use-context-presents";
export default defineNuxtComponent({
props: {
items: {
type: Array as () => ContextMenuItem[],
required: true,
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "grey-darken-2",
},
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

@@ -4,20 +4,13 @@
</pre>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
data: {
type: Object,
required: true,
},
},
setup(props) {
const prettyJson = JSON.stringify(props.data, null, 2);
return {
prettyJson,
};
<script setup lang="ts">
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const prettyJson = JSON.stringify(props.data, null, 2);
</script>

View File

@@ -15,21 +15,16 @@
</v-btn>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
link: {
type: String,
required: true,
},
},
setup(props) {
const href = computed(() => {
// TODO: dynamically set docs link based off env
return `https://nightly.mealie.io${props.link}`;
});
return { href };
<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

@@ -19,25 +19,27 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useDropZone } from "@vueuse/core";
export default defineNuxtComponent({
emits: ["drop"],
setup(_, context) {
const el = ref<HTMLDivElement>();
function onDrop(files: File[] | null) {
if (files) {
context.emit("drop", files);
}
}
const { isOverDropZone } = useDropZone(el, files => onDrop(files));
return { el, isOverDropZone };
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">

View File

@@ -29,17 +29,15 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
small: {
type: Boolean,
default: false,
},
right: {
type: Boolean,
default: false,
},
<script setup lang="ts">
defineProps({
small: {
type: Boolean,
default: false,
},
right: {
type: Boolean,
default: false,
},
});
</script>

View File

@@ -16,11 +16,11 @@
color="success"
:icon="$globals.icons.save"
:disabled="submitted"
@click="() => save()"
@click="save"
/>
<v-menu offset-y :close-on-content-click="false" location="bottom center">
<template #activator="{ props }">
<v-btn color="info" v-bind="props" :icon="$globals.icons.edit" :disabled="submitted" />
<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">
@@ -55,117 +55,98 @@
</v-card>
</template>
<script lang="ts">
<script setup lang="ts">
import { Cropper } from "vue-advanced-cropper";
import "vue-advanced-cropper/dist/style.css";
export default defineNuxtComponent({
components: { Cropper },
props: {
img: {
type: String,
required: true,
},
cropperHeight: {
type: String,
default: undefined,
},
cropperWidth: {
type: String,
default: undefined,
},
submitted: {
type: Boolean,
default: false,
},
defineProps({
img: {
type: String,
required: true,
},
emits: ["save", "delete"],
setup(_, context) {
const cropper = ref<any>();
const changed = ref(0);
const { $globals } = useNuxtApp();
interface Control {
color: string;
icon: string;
callback: CallableFunction;
}
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 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;
}
function save() {
if (!cropper.value) {
return;
}
const { canvas } = cropper.value.getResult();
if (!canvas) {
return;
}
canvas.toBlob((blob) => {
if (blob) {
context.emit("save", blob);
}
});
}
return {
cropper,
controls,
flip,
rotate,
save,
changed,
};
cropperHeight: {
type: String,
default: undefined,
},
methods: {
defaultSize({ imageSize, visibleArea }) {
return {
width: (visibleArea || imageSize).width,
height: (visibleArea || imageSize).height,
};
},
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

@@ -1,6 +1,6 @@
<template>
<v-text-field
v-model="inputVal"
v-model="modelValue"
:label="$t('general.color')"
>
<template #prepend>
@@ -9,7 +9,7 @@
size="small"
height="30px"
width="30px"
:color="inputVal || 'grey'"
:color="modelValue || 'grey'"
@click="setRandomHex"
>
<v-icon color="white">
@@ -33,7 +33,7 @@
<v-card>
<v-card-text class="pa-0">
<v-color-picker
v-model="inputVal"
v-model="modelValue"
flat
hide-inputs
show-swatches
@@ -46,42 +46,21 @@
</v-text-field>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: String,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const menu = ref(false);
const inputVal = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
function getRandomHex() {
return "#000000".replace(/0/g, function () {
return (~~(Math.random() * 16)).toString(16);
});
}
function setRandomHex() {
inputVal.value = getRandomHex();
}
return {
menu,
setRandomHex,
inputVal,
};
},
<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

@@ -30,105 +30,60 @@
</v-autocomplete>
</template>
<script lang="ts">
/**
* The InputLabelType component is a wrapper for v-autocomplete. It is used to abstract the selection functionality
* of some common types within Mealie. This can mostly be used with any type of object provided it has a name and id
* property. The name property is used to display the name of the object in the autocomplete dropdown. The id property
* is used to store the id of the object in the itemId property.
*
* Supported Types
* - MultiPurposeLabel
* - RecipeIngredientFood
* - RecipeIngredientUnit
*
* TODO: Add RecipeTag / Category to this selector
* Future Supported Types
* - RecipeTags
* - RecipeCategories
*
* Both the ID and Item can be synced. The item can be synced using the v-model syntax and the itemId can be synced
* using the .sync syntax `item-id.sync="item.labelId"`
*/
<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";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object as () => MultiPurposeLabelSummary | IngredientFood | IngredientUnit,
required: false,
default: () => {
return {};
},
},
items: {
type: Array as () => Array<MultiPurposeLabelSummary | IngredientFood | IngredientUnit>,
required: true,
},
itemId: {
type: [String, Number],
default: undefined,
},
icon: {
type: String,
required: false,
default: undefined,
},
create: {
type: Boolean,
default: false,
},
// 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,
},
emits: ["update:modelValue", "update:item-id", "create"],
setup(props, context) {
const autocompleteRef = ref<HTMLInputElement>();
// Use the search composable
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
const itemIdVal = computed({
get: () => {
return props.itemId || undefined;
},
set: (val) => {
context.emit("update:item-id", val);
},
});
const itemVal = computed({
get: () => {
try {
return Object.keys(props.modelValue).length !== 0 ? props.modelValue : null;
}
catch {
return null;
}
},
set: (val) => {
itemIdVal.value = val?.id || undefined;
context.emit("update:modelValue", val);
},
});
function emitCreate() {
if (props.items.some(item => item.name === searchInput.value)) {
return;
}
context.emit("create", searchInput.value);
autocompleteRef.value?.blur();
}
return {
autocompleteRef,
itemVal,
itemIdVal,
searchInput,
filteredItems,
emitCreate,
};
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

@@ -1,6 +1,6 @@
<template>
<BaseDialog
v-model="dialog"
v-model="modelValue"
:icon="$globals.icons.translate"
:title="$t('language-dialog.choose-language')"
>
@@ -43,50 +43,26 @@
</BaseDialog>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const dialog = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value),
});
const modelValue = defineModel<boolean>({ default: () => false });
const { locales: LOCALES, locale, i18n } = useLocales();
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;
}
};
const selectedLocale = ref(locale.value);
const onLocaleSelect = (value: string) => {
if (value && locales.some(l => l.value === value)) {
locale.value = value as any;
}
};
watch(locale, () => {
dialog.value = false; // Close dialog when locale changes
});
const locales = LOCALES.filter(lc =>
i18n.locales.value.map(i18nLocale => i18nLocale.code).includes(lc.value as any),
);
return {
dialog,
i18n,
locales,
locale,
selectedLocale,
onLocaleSelect,
normalizeFilter,
};
},
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

@@ -18,7 +18,7 @@
<v-textarea
v-if="!previewState"
v-bind="textarea"
v-model="inputVal"
v-model="modelValue"
:class="label == '' ? '' : 'mt-5'"
:label="label"
auto-grow
@@ -33,60 +33,42 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
name: "MarkdownEditor",
props: {
modelValue: {
type: String,
required: true,
},
label: {
type: String,
default: "",
},
preview: {
type: Boolean,
default: undefined,
},
displayPreview: {
type: Boolean,
default: true,
},
textarea: {
type: Object as () => unknown,
default: () => ({}),
},
<script setup lang="ts">
const props = defineProps({
label: {
type: String,
default: "",
},
emits: ["update:modelValue", "input:preview"],
setup(props, context) {
const fallbackPreview = ref(false);
const previewState = computed({
get: () => {
return props.preview ?? fallbackPreview.value;
},
set: (val) => {
if (props.preview) {
context.emit("input:preview", val);
}
else {
fallbackPreview.value = val;
}
},
});
preview: {
type: Boolean,
default: undefined,
},
displayPreview: {
type: Boolean,
default: true,
},
textarea: {
type: Object as () => unknown,
default: () => ({}),
},
});
const inputVal = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
return {
previewState,
inputVal,
};
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

@@ -8,53 +8,40 @@
/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
<script setup lang="ts">
import JsonEditorVue from "json-editor-vue";
export default defineComponent({
name: "RecipeJsonEditor",
components: { JsonEditorVue },
props: {
modelValue: {
type: Object,
default: () => ({}),
},
height: {
type: String,
default: "1500px",
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
function parseEvent(event: any): object {
if (!event) {
return props.modelValue || {};
}
try {
if (event.json) {
return event.json;
}
else if (event.text) {
return JSON.parse(event.text);
}
else {
return event;
}
}
catch {
return props.modelValue || {};
}
}
function onChange(event: any) {
const parsed = parseEvent(event);
if (parsed !== props.modelValue) {
emit("update:modelValue", parsed);
}
}
return {
onChange,
};
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

@@ -27,54 +27,46 @@
</v-data-table>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReportSummary } from "~/lib/api/types/reports";
export default defineNuxtComponent({
props: {
items: {
required: true,
type: Array as () => Array<ReportSummary>,
},
},
emits: ["delete"],
setup(_, context) {
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) {
context.emit("delete", id);
}
return {
headers,
handleRowClick,
capitalize,
deleteReport,
};
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

@@ -3,7 +3,7 @@
<div v-html="value" />
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
@@ -11,62 +11,55 @@ enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
}
export default defineNuxtComponent({
props: {
source: {
type: String,
default: "",
},
const props = defineProps({
source: {
type: String,
default: "",
},
setup(props) {
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 "";
}
const ALLOWED_STYLE_TAGS = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
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(";");
}
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());
});
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;
data.attrValue = styles.join(";");
}
});
const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml);
});
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",
],
});
return {
value,
};
},
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>

View File

@@ -30,33 +30,26 @@
</v-card>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
icon: {
type: String,
default: null,
},
minWidth: {
type: String,
default: "",
},
to: {
type: String,
default: null,
},
<script setup lang="ts">
const props = defineProps({
icon: {
type: String,
default: null,
},
setup(props) {
const { $globals } = useNuxtApp();
const activeIcon = computed(() => {
return props.icon ?? $globals.icons.primary;
});
return {
activeIcon,
};
minWidth: {
type: String,
default: "",
},
to: {
type: String,
default: null,
},
});
const { $globals } = useNuxtApp();
const activeIcon = computed(() => {
return props.icon ?? $globals.icons.primary;
});
</script>

View File

@@ -2,40 +2,26 @@
<component :is="tag">
<slot
name="activator"
v-bind="{ toggle, state }"
v-bind="{ toggle, modelValue }"
/>
<slot v-bind="{ state, toggle }" />
<slot v-bind="{ modelValue, toggle }" />
</component>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
tag: {
type: String,
default: "div",
},
},
emits: ["update:modelValue"],
setup(props, context) {
const state = ref(false);
<script setup lang="ts">
const modelValue = defineModel({
type: Boolean,
default: false,
});
const toggle = () => {
state.value = !state.value;
};
watch(state, () => {
context.emit("update:modelValue", state.value);
});
return {
state,
toggle,
};
defineProps({
tag: {
type: String,
default: "div",
},
});
const toggle = () => {
modelValue.value = !modelValue.value;
};
</script>

View File

@@ -12,42 +12,33 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useWakeLock } from "@vueuse/core";
export default defineNuxtComponent({
setup() {
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");
}
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
const wakeLock = computed({
get: () => isActive.value,
set: () => {
if (isActive.value) {
unlockScreen();
}
async function unlockScreen() {
if (wakeIsSupported || isActive) {
console.debug("Wake Lock Released");
await release();
}
else {
lockScreen();
}
onMounted(() => lockScreen());
onUnmounted(() => unlockScreen());
return {
wakeLock,
wakeIsSupported,
};
},
});
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>