mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-19 03:15:35 -04:00
feat: Announcements (#7431)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
v-if="currentAnnouncement"
|
||||||
|
v-model="dialog"
|
||||||
|
:title="$t('announcements.announcements')"
|
||||||
|
:icon="$globals.icons.bullhornVariant"
|
||||||
|
:cancel-text="$t('general.done')"
|
||||||
|
width="100%"
|
||||||
|
max-width="1200"
|
||||||
|
>
|
||||||
|
<div class="d-flex" :style="{ height: useMobile ? '100%' : '60vh', minHeight: '60vh' }">
|
||||||
|
<!-- Nav list -->
|
||||||
|
<v-list
|
||||||
|
v-show="!useMobile || navOpen"
|
||||||
|
nav
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
class="overflow-y-auto border-e flex-shrink-0"
|
||||||
|
style="width: 200px; max-height: 60vh"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="announcement in allAnnouncements.toReversed()"
|
||||||
|
:key="announcement.key"
|
||||||
|
:active="currentAnnouncement.key === announcement.key"
|
||||||
|
rounded
|
||||||
|
@click="setCurrentAnnouncement(announcement); navOpen = false"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="text-body-2">
|
||||||
|
{{ announcement.meta?.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="announcement.date">
|
||||||
|
{{ $d(announcement.date) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
|
<template v-if="newAnnouncements.some(a => a.key === announcement.key)" #append>
|
||||||
|
<v-icon size="x-small" color="info">
|
||||||
|
{{ $globals.icons.alertCircle }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div
|
||||||
|
class="flex-grow-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-if="useMobile"
|
||||||
|
:prepend-icon="navOpen ? $globals.icons.chevronLeft : $globals.icons.chevronRight"
|
||||||
|
density="compact"
|
||||||
|
variant="text"
|
||||||
|
class="mt-2 ms-2"
|
||||||
|
@click="navOpen = !navOpen"
|
||||||
|
>
|
||||||
|
{{ $t("announcements.all-announcements") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-card-title>
|
||||||
|
<v-chip v-if="currentAnnouncement.date" label large class="me-1">
|
||||||
|
<v-icon class="me-1">
|
||||||
|
{{ $globals.icons.calendar }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $d(currentAnnouncement.date) }}
|
||||||
|
</v-chip>
|
||||||
|
{{ currentAnnouncement.meta?.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<component :is="currentAnnouncement.component" />
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #custom-card-action>
|
||||||
|
<BaseButton
|
||||||
|
v-if="newAnnouncements.length"
|
||||||
|
color="success"
|
||||||
|
:icon="$globals.icons.textBoxCheckOutline"
|
||||||
|
:text="$t('announcements.mark-all-as-read')"
|
||||||
|
@click="markAllAsRead"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
:disabled="isLastAnnouncement(currentAnnouncement.key)"
|
||||||
|
color="info"
|
||||||
|
:icon="$globals.icons.arrowRightBold"
|
||||||
|
icon-right
|
||||||
|
:text="$t('general.next')"
|
||||||
|
@click="nextAnnouncement"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAnnouncements } from "~/composables/use-announcements";
|
||||||
|
import type { Announcement } from "~/composables/use-announcements";
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const display = useDisplay();
|
||||||
|
const useMobile = computed(() => display.smAndDown.value);
|
||||||
|
const navOpen = ref(false);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
watch(() => route.fullPath, () => { dialog.value = false; });
|
||||||
|
|
||||||
|
const { newAnnouncements, allAnnouncements, setLastRead, markAllAsRead } = useAnnouncements();
|
||||||
|
|
||||||
|
const currentAnnouncement = shallowRef<Announcement | undefined>();
|
||||||
|
|
||||||
|
watch(dialog, () => {
|
||||||
|
if (!dialog.value || currentAnnouncement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show first unread on open, or fall back to the newest
|
||||||
|
const next = newAnnouncements.value.at(0) || allAnnouncements.at(-1)!;
|
||||||
|
setCurrentAnnouncement(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setCurrentAnnouncement(announcement: Announcement) {
|
||||||
|
currentAnnouncement.value = announcement;
|
||||||
|
setLastRead(announcement.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAnnouncement() {
|
||||||
|
// Find the first unread announcement after the current one (current is already removed from newAnnouncements)
|
||||||
|
const next = newAnnouncements.value.find(a => a.key > currentAnnouncement.value!.key);
|
||||||
|
if (next) {
|
||||||
|
setCurrentAnnouncement(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLastAnnouncement(key: string) {
|
||||||
|
if (!newAnnouncements.value.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return key >= newAnnouncements.value.at(-1)!.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Welcome to Mealie! If this is your first time seeing announcements, here's what to expect.
|
||||||
|
</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
Announcements are reserved for things like:
|
||||||
|
<ul class="ml-6">
|
||||||
|
<li>Important new features</li>
|
||||||
|
<li>Major changes</li>
|
||||||
|
<li>Anything that might require additional user actions (such as migration scripts)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
While we generally keep everything in our <a class="text-primary" href="https://github.com/mealie-recipes/mealie/releases" target="_blank">GitHub release notes</a>,
|
||||||
|
sometimes certain changes require some extra attention.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Announcements are English-only; they're one-off messages from the maintainers, not a replacement for our release notes. Some elements may still be translated.
|
||||||
|
</p>
|
||||||
|
<hr class="mt-2 mb-4">
|
||||||
|
<p>
|
||||||
|
You can opt out of announcements in your user settings:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/user/profile/edit">
|
||||||
|
{{ $t("profile.user-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
<p v-if="user?.canManageHousehold" class="mt-3">
|
||||||
|
As {{ user?.admin ? "an admin" : "a household manager" }}, you can disable announcements for your entire household:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/household">
|
||||||
|
{{ $t("profile.household-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
<p v-if="user?.canManage" class="mt-3">
|
||||||
|
{{ user?.admin ? "You can also" : "As a group manager, you can" }} disable announcements for your entire group:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/group">
|
||||||
|
{{ $t("profile.group-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||||
|
|
||||||
|
const { user } = useMealieAuth();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const meta: AnnouncementMeta = {
|
||||||
|
title: "Welcome to Mealie 🎉",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
p {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
const announcementFiles = import.meta.glob<{ default: unknown }>(
|
||||||
|
"~/components/Domain/Announcement/Announcements/*.vue",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expected format: YYYY-MM-DD_N_slug e.g. 2026-03-27_1_welcome
|
||||||
|
const FILE_FORMAT = /^\d{4}-\d{2}-\d{2}_\d+_.+$/;
|
||||||
|
|
||||||
|
describe("Announcement files", () => {
|
||||||
|
const filenames = Object.keys(announcementFiles).map(path =>
|
||||||
|
path.split("/").at(-1)!.replace(".vue", ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("directory is not empty", () => {
|
||||||
|
expect(filenames.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all filenames match YYYY-MM-DD_N_slug format", () => {
|
||||||
|
for (const name of filenames) {
|
||||||
|
expect(name, `"${name}" does not match the expected format`).toMatch(FILE_FORMAT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all date prefixes are valid dates", () => {
|
||||||
|
for (const name of filenames) {
|
||||||
|
const datePart = name.split("_", 1)[0]!;
|
||||||
|
const date = new Date(datePart);
|
||||||
|
expect(isNaN(date.getTime()), `"${name}" has an invalid date prefix "${datePart}"`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all filenames are unique", () => {
|
||||||
|
const unique = new Set(filenames);
|
||||||
|
expect(unique.size).toBe(filenames.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle :title="$t('group.general-preferences')" />
|
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
|
||||||
|
<div class="mb-6">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="preferences.privateGroup"
|
v-model="preferences.privateGroup"
|
||||||
class="mt-n4"
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
:label="$t('group.private-group')"
|
:label="$t('group.private-group')"
|
||||||
/>
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("group.private-group-description") }}
|
||||||
|
</p>
|
||||||
|
<DocLink
|
||||||
|
class="mt-2"
|
||||||
|
link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="preferences.showAnnouncements"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
:label="$t('announcements.show-announcements-from-mealie')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("announcements.show-announcements-setting-description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,5 +41,3 @@ import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
|||||||
|
|
||||||
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="preferences.showAnnouncements"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
:label="$t('announcements.show-announcements-from-mealie')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("announcements.show-announcements-setting-description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="preferences.firstDayOfWeek"
|
v-model="preferences.firstDayOfWeek"
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-navigation-drawer v-model="modelValue" class="d-flex flex-column d-print-none position-fixed" touchless>
|
<v-navigation-drawer v-model="modelValue" class="d-flex flex-column d-print-none position-fixed" touchless>
|
||||||
|
<AnnouncementDialog v-model="showAnnouncementsDialog" />
|
||||||
<LanguageDialog v-model="state.languageDialog" />
|
<LanguageDialog v-model="state.languageDialog" />
|
||||||
<!-- User Profile -->
|
<!-- User Profile -->
|
||||||
<template v-if="loggedIn && sessionUser">
|
<template v-if="loggedIn && sessionUser">
|
||||||
@@ -117,6 +118,24 @@
|
|||||||
<!-- Bottom Navigation Links -->
|
<!-- Bottom Navigation Links -->
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-list v-model:selected="state.bottomSelected" nav density="comfortable">
|
<v-list v-model:selected="state.bottomSelected" nav density="comfortable">
|
||||||
|
<v-list-item
|
||||||
|
v-if="loggedIn && announcementsEnabled"
|
||||||
|
:title="$t('announcements.announcements')"
|
||||||
|
@click="() => showAnnouncementsDialog = !showAnnouncementsDialog"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-badge
|
||||||
|
:model-value="!!newAnnouncements.length"
|
||||||
|
color="accent"
|
||||||
|
:content="newAnnouncements.length || undefined"
|
||||||
|
offset-x="-2"
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.bullhornVariant }}
|
||||||
|
</v-icon>
|
||||||
|
</v-badge>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
<v-menu location="end bottom" :offset="15">
|
<v-menu location="end bottom" :offset="15">
|
||||||
<template #activator="{ props: hoverProps }">
|
<template #activator="{ props: hoverProps }">
|
||||||
<v-list-item v-bind="hoverProps" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
|
<v-list-item v-bind="hoverProps" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
|
||||||
@@ -139,8 +158,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { SidebarLinks } from "~/types/application-types";
|
import type { SidebarLinks } from "~/types/application-types";
|
||||||
|
import AnnouncementDialog from "~/components/Domain/Announcement/AnnouncementDialog.vue";
|
||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||||
|
import { useAnnouncements } from "~/composables/use-announcements";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
user: {
|
user: {
|
||||||
@@ -171,6 +192,9 @@ const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undef
|
|||||||
|
|
||||||
const toggleDark = useToggleDarkMode();
|
const toggleDark = useToggleDarkMode();
|
||||||
|
|
||||||
|
const showAnnouncementsDialog = ref(false);
|
||||||
|
const { announcementsEnabled, newAnnouncements } = useAnnouncements();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dropDowns: {} as Record<string, boolean>,
|
dropDowns: {} as Record<string, boolean>,
|
||||||
secondarySelected: null as string[] | null,
|
secondarySelected: null as string[] | null,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|
||||||
<div>
|
<div style="flex: 1 1 auto; min-height: 0; overflow: auto">
|
||||||
<slot v-bind="{ submitEvent }" />
|
<slot v-bind="{ submitEvent }" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
emit('cancel');
|
emit('cancel');
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ $t("general.cancel") }}
|
{{ cancelText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
@@ -110,10 +110,16 @@ interface DialogProps {
|
|||||||
maxWidth?: number | string | null;
|
maxWidth?: number | string | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
top?: boolean | null;
|
top?: boolean | null;
|
||||||
|
keepOpen?: boolean;
|
||||||
|
|
||||||
|
// submit
|
||||||
submitIcon?: string | null;
|
submitIcon?: string | null;
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
submitDisabled?: boolean;
|
submitDisabled?: boolean;
|
||||||
keepOpen?: boolean;
|
|
||||||
|
// cancel
|
||||||
|
cancelText?: string;
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
canConfirm?: boolean;
|
canConfirm?: boolean;
|
||||||
@@ -135,10 +141,17 @@ const props = withDefaults(defineProps<DialogProps>(), {
|
|||||||
maxWidth: null,
|
maxWidth: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
top: null,
|
top: null,
|
||||||
|
keepOpen: false,
|
||||||
|
|
||||||
|
// submit
|
||||||
submitIcon: null,
|
submitIcon: null,
|
||||||
submitText: () => useNuxtApp().$i18n.t("general.create"),
|
submitText: () => useNuxtApp().$i18n.t("general.create"),
|
||||||
submitDisabled: false,
|
submitDisabled: false,
|
||||||
keepOpen: false,
|
|
||||||
|
// cancel
|
||||||
|
cancelText: () => useNuxtApp().$i18n.t("general.cancel"),
|
||||||
|
|
||||||
|
// actions
|
||||||
canDelete: false,
|
canDelete: false,
|
||||||
canConfirm: false,
|
canConfirm: false,
|
||||||
canSubmit: false,
|
canSubmit: false,
|
||||||
|
|||||||
135
frontend/app/composables/use-announcements.ts
Normal file
135
frontend/app/composables/use-announcements.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
|
export type AnnouncementMeta = {
|
||||||
|
title: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Announcement = {
|
||||||
|
key: string;
|
||||||
|
component: Component;
|
||||||
|
date: Date | undefined;
|
||||||
|
meta: AnnouncementMeta | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _announcementsUnsorted = import.meta.glob<{ default: Component; meta?: AnnouncementMeta }>(
|
||||||
|
"~/components/Domain/Announcement/Announcements/*.vue",
|
||||||
|
{ eager: true },
|
||||||
|
);
|
||||||
|
const allAnnouncements: Announcement[] = Object.entries(_announcementsUnsorted)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([path, mod]) => {
|
||||||
|
const key = path.split("/").at(-1)!.replace(".vue", "");
|
||||||
|
|
||||||
|
const parsed = new Date(key.split("_", 1)[0]!);
|
||||||
|
const date = isNaN(parsed.getTime()) ? undefined : parsed;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
component: mod.default,
|
||||||
|
date,
|
||||||
|
meta: mod.meta,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const newAnnouncements = shallowRef<Announcement[]>([]);
|
||||||
|
|
||||||
|
function isWelcomeAnnouncement(key: string) {
|
||||||
|
return key === allAnnouncements.at(0)!.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnnouncements() {
|
||||||
|
const auth = useMealieAuth();
|
||||||
|
const api = useUserApi();
|
||||||
|
const { household } = useHouseholdSelf();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
|
|
||||||
|
const announcementsEnabled = computed(
|
||||||
|
() =>
|
||||||
|
!!(
|
||||||
|
auth.user.value?.showAnnouncements
|
||||||
|
&& household.value?.preferences?.showAnnouncements
|
||||||
|
&& group.value?.preferences?.showAnnouncements
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateUnreadAnnouncements(lastReadKey: string) {
|
||||||
|
newAnnouncements.value = allAnnouncements.filter(a => a.key > lastReadKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLastRead(key: string) {
|
||||||
|
const user = auth.user.value!;
|
||||||
|
|
||||||
|
if (!user.lastReadAnnouncement && isWelcomeAnnouncement(key)) {
|
||||||
|
// The welcome announcement is a special case: it's shown to new users and
|
||||||
|
// all other announcements are marked as read when they view it
|
||||||
|
key = allAnnouncements.at(-1)!.key;
|
||||||
|
updateUnreadAnnouncements(key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Only mark this specific announcement as read in the current session
|
||||||
|
newAnnouncements.value = newAnnouncements.value.filter(a => a.key !== key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.lastReadAnnouncement && key <= user.lastReadAnnouncement) {
|
||||||
|
// Don't update the last read announcement if it's older than the current one
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.lastReadAnnouncement = key; // update immediately so we don't have to wait for the db
|
||||||
|
await api.users.updateOne(
|
||||||
|
user.id,
|
||||||
|
{
|
||||||
|
...user,
|
||||||
|
lastReadAnnouncement: key,
|
||||||
|
},
|
||||||
|
{ suppressAlert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsRead() {
|
||||||
|
setLastRead(allAnnouncements.at(-1)!.key);
|
||||||
|
newAnnouncements.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUnreadAnnouncements() {
|
||||||
|
const user = auth.user.value;
|
||||||
|
|
||||||
|
// Only logged-in users can see announcements
|
||||||
|
if (!user || !allAnnouncements.length) {
|
||||||
|
newAnnouncements.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a user has never seen an announcement, show them only the welcome announcement
|
||||||
|
if (!user.lastReadAnnouncement) {
|
||||||
|
newAnnouncements.value = [allAnnouncements.at(0)!];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all announcements newer than the last read announcement
|
||||||
|
updateUnreadAnnouncements(user.lastReadAnnouncement);
|
||||||
|
}
|
||||||
|
|
||||||
|
initUnreadAnnouncements();
|
||||||
|
|
||||||
|
// If the user changes, re-init
|
||||||
|
let lastUserId = auth.user.value?.id;
|
||||||
|
watch(auth.user, () => {
|
||||||
|
if (auth.user.value?.id === lastUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUserId = auth.user.value?.id;
|
||||||
|
initUnreadAnnouncements();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
announcementsEnabled,
|
||||||
|
newAnnouncements,
|
||||||
|
allAnnouncements,
|
||||||
|
setLastRead,
|
||||||
|
markAllAsRead,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export const useGroupSelf = function () {
|
|||||||
if (data) {
|
if (data) {
|
||||||
groupSelfRef.value.preferences = data;
|
groupSelfRef.value.preferences = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return data || undefined;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
|
"done": "Done",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -1472,5 +1473,12 @@
|
|||||||
"no-whitespace": "No Whitespace Allowed",
|
"no-whitespace": "No Whitespace Allowed",
|
||||||
"min-length": "Must Be At Least {min} Characters",
|
"min-length": "Must Be At Least {min} Characters",
|
||||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||||
|
},
|
||||||
|
"announcements": {
|
||||||
|
"announcements": "Announcements",
|
||||||
|
"all-announcements": "All announcements",
|
||||||
|
"mark-all-as-read": "Mark All as Read",
|
||||||
|
"show-announcements-from-mealie": "Show announcements from Mealie",
|
||||||
|
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,4 @@ const routes = {
|
|||||||
export class AdminGroupsApi extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate> {
|
export class AdminGroupsApi extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate> {
|
||||||
baseRoute: string = routes.adminUsers;
|
baseRoute: string = routes.adminUsers;
|
||||||
itemRoute = routes.adminUsersId;
|
itemRoute = routes.adminUsersId;
|
||||||
|
|
||||||
async updateOne(id: string, payload: GroupAdminUpdate) {
|
|
||||||
// TODO: This should probably be a patch request, which isn't offered by the API currently
|
|
||||||
return await this.requests.put<GroupInDB, GroupAdminUpdate>(this.itemRoute(id), payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AxiosRequestConfig } from "axios";
|
||||||
import type { Recipe } from "../types/recipe";
|
import type { Recipe } from "../types/recipe";
|
||||||
import type { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
|
import type { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
|
||||||
import { type QueryValue, route } from "~/lib/api/base/route";
|
import { type QueryValue, route } from "~/lib/api/base/route";
|
||||||
@@ -44,38 +45,38 @@ export abstract class BaseCRUDAPIReadOnly<ReadType>
|
|||||||
return this.itemRouteFn(itemId);
|
return this.itemRouteFn(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
async getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>, config?: AxiosRequestConfig) {
|
||||||
params = Object.fromEntries(Object.entries(params).filter(([_, v]) => v !== null && v !== undefined));
|
params = Object.fromEntries(Object.entries(params).filter(([_, v]) => v !== null && v !== undefined));
|
||||||
return await this.requests.get<PaginationData<ReadType>>(route(this.baseRoute, { page, perPage, ...params }));
|
return await this.requests.get<PaginationData<ReadType>>(route(this.baseRoute, { page, perPage, ...params }), undefined, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOne(itemId: string | number) {
|
async getOne(itemId: string | number, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.get<ReadType>(this.itemRoute(itemId));
|
return await this.requests.get<ReadType>(this.itemRoute(itemId), undefined, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
|
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
|
||||||
extends BaseCRUDAPIReadOnly<ReadType>
|
extends BaseCRUDAPIReadOnly<ReadType>
|
||||||
implements CrudAPIInterface {
|
implements CrudAPIInterface {
|
||||||
async createOne(payload: CreateType) {
|
async createOne(payload: CreateType, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.post<ReadType>(this.baseRoute, payload);
|
return await this.requests.post<ReadType>(this.baseRoute, payload, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOne(itemId: string | number, payload: UpdateType) {
|
async updateOne(itemId: string | number, payload: UpdateType, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.put<ReadType, UpdateType>(this.itemRoute(itemId), payload);
|
return await this.requests.put<ReadType, UpdateType>(this.itemRoute(itemId), payload, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async patchOne(itemId: string, payload: Partial<UpdateType>) {
|
async patchOne(itemId: string, payload: Partial<UpdateType>, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.patch<ReadType, Partial<UpdateType>>(this.itemRoute(itemId), payload);
|
return await this.requests.patch<ReadType, Partial<UpdateType>>(this.itemRoute(itemId), payload, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteOne(itemId: string | number) {
|
async deleteOne(itemId: string | number, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.delete<ReadType>(this.itemRoute(itemId));
|
return await this.requests.delete<ReadType>(this.itemRoute(itemId), config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async duplicateOne(itemId: string | number, newName: string | undefined) {
|
async duplicateOne(itemId: string | number, newName: string | undefined, config?: AxiosRequestConfig) {
|
||||||
return await this.requests.post<Recipe>(`${this.itemRoute(itemId)}/duplicate`, {
|
return await this.requests.post<Recipe>(`${this.itemRoute(itemId)}/duplicate`, {
|
||||||
name: newName,
|
name: newName,
|
||||||
});
|
}, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type SupportedMigrations =
|
|||||||
|
|
||||||
export interface CreateGroupPreferences {
|
export interface CreateGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
export interface DataMigrationCreate {
|
export interface DataMigrationCreate {
|
||||||
@@ -31,6 +32,7 @@ export interface GroupAdminUpdate {
|
|||||||
}
|
}
|
||||||
export interface UpdateGroupPreferences {
|
export interface UpdateGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
}
|
}
|
||||||
export interface GroupDataExport {
|
export interface GroupDataExport {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,6 +51,7 @@ export interface GroupStorage {
|
|||||||
}
|
}
|
||||||
export interface ReadGroupPreferences {
|
export interface ReadGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface CreateGroupRecipeAction {
|
|||||||
}
|
}
|
||||||
export interface CreateHouseholdPreferences {
|
export interface CreateHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
lockRecipeEditsFromOtherHouseholds?: boolean;
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
@@ -199,6 +200,7 @@ export interface HouseholdInDB {
|
|||||||
}
|
}
|
||||||
export interface ReadHouseholdPreferences {
|
export interface ReadHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
lockRecipeEditsFromOtherHouseholds?: boolean;
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
@@ -276,6 +278,7 @@ export interface SaveGroupRecipeAction {
|
|||||||
}
|
}
|
||||||
export interface SaveHouseholdPreferences {
|
export interface SaveHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
lockRecipeEditsFromOtherHouseholds?: boolean;
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
@@ -769,6 +772,7 @@ export interface UpdateHouseholdAdmin {
|
|||||||
}
|
}
|
||||||
export interface UpdateHouseholdPreferences {
|
export interface UpdateHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
lockRecipeEditsFromOtherHouseholds?: boolean;
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export interface UserSummary {
|
|||||||
}
|
}
|
||||||
export interface ReadGroupPreferences {
|
export interface ReadGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,8 @@ export interface PrivateUser {
|
|||||||
group: string;
|
group: string;
|
||||||
household: string;
|
household: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
|
lastReadAnnouncement?: string | null;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canManageHousehold?: boolean;
|
canManageHousehold?: boolean;
|
||||||
@@ -194,6 +197,8 @@ export interface UserBase {
|
|||||||
group?: string | null;
|
group?: string | null;
|
||||||
household?: string | null;
|
household?: string | null;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
|
lastReadAnnouncement?: string | null;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canManageHousehold?: boolean;
|
canManageHousehold?: boolean;
|
||||||
@@ -209,6 +214,8 @@ export interface UserIn {
|
|||||||
group?: string | null;
|
group?: string | null;
|
||||||
household?: string | null;
|
household?: string | null;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
|
lastReadAnnouncement?: string | null;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canManageHousehold?: boolean;
|
canManageHousehold?: boolean;
|
||||||
@@ -225,6 +232,8 @@ export interface UserOut {
|
|||||||
group: string;
|
group: string;
|
||||||
household: string;
|
household: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
|
showAnnouncements?: boolean;
|
||||||
|
lastReadAnnouncement?: string | null;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canManageHousehold?: boolean;
|
canManageHousehold?: boolean;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
mdiBookOutline,
|
mdiBookOutline,
|
||||||
mdiBowlMixOutline,
|
mdiBowlMixOutline,
|
||||||
mdiBroom,
|
mdiBroom,
|
||||||
|
mdiBullhornVariant,
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
mdiCalendarMinus,
|
mdiCalendarMinus,
|
||||||
mdiCalendarMultiselect,
|
mdiCalendarMultiselect,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
mdiChefHat,
|
mdiChefHat,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
|
mdiChevronLeft,
|
||||||
mdiClipboardCheck,
|
mdiClipboardCheck,
|
||||||
mdiClockTimeFourOutline,
|
mdiClockTimeFourOutline,
|
||||||
mdiClose,
|
mdiClose,
|
||||||
@@ -144,6 +146,7 @@ import {
|
|||||||
mdiTestTube,
|
mdiTestTube,
|
||||||
mdiText,
|
mdiText,
|
||||||
mdiTextBoxOutline,
|
mdiTextBoxOutline,
|
||||||
|
mdiTextBoxCheckOutline,
|
||||||
mdiTimelineText,
|
mdiTimelineText,
|
||||||
mdiTimerSand,
|
mdiTimerSand,
|
||||||
mdiTools,
|
mdiTools,
|
||||||
@@ -157,6 +160,7 @@ import {
|
|||||||
mdiWindowClose,
|
mdiWindowClose,
|
||||||
mdiWrench,
|
mdiWrench,
|
||||||
mdiHandWaveOutline,
|
mdiHandWaveOutline,
|
||||||
|
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
|
|
||||||
export const icons = {
|
export const icons = {
|
||||||
@@ -184,6 +188,7 @@ export const icons = {
|
|||||||
bellAlert: mdiBellAlert,
|
bellAlert: mdiBellAlert,
|
||||||
bellPlus: mdiBellPlus,
|
bellPlus: mdiBellPlus,
|
||||||
broom: mdiBroom,
|
broom: mdiBroom,
|
||||||
|
bullhornVariant: mdiBullhornVariant,
|
||||||
calendar: mdiCalendar,
|
calendar: mdiCalendar,
|
||||||
calendarMinus: mdiCalendarMinus,
|
calendarMinus: mdiCalendarMinus,
|
||||||
calendarMultiselect: mdiCalendarMultiselect,
|
calendarMultiselect: mdiCalendarMultiselect,
|
||||||
@@ -277,6 +282,7 @@ export const icons = {
|
|||||||
sortClockDescending: mdiSortClockDescending,
|
sortClockDescending: mdiSortClockDescending,
|
||||||
star: mdiStar,
|
star: mdiStar,
|
||||||
testTube: mdiTestTube,
|
testTube: mdiTestTube,
|
||||||
|
textBoxCheckOutline: mdiTextBoxCheckOutline,
|
||||||
timelineText: mdiTimelineText,
|
timelineText: mdiTimelineText,
|
||||||
tools: mdiTools,
|
tools: mdiTools,
|
||||||
potSteam: mdiPotSteamOutline,
|
potSteam: mdiPotSteamOutline,
|
||||||
@@ -327,6 +333,7 @@ export const icons = {
|
|||||||
slotMachine: mdiSlotMachine,
|
slotMachine: mdiSlotMachine,
|
||||||
chevronDown: mdiChevronDown,
|
chevronDown: mdiChevronDown,
|
||||||
chevronRight: mdiChevronRight,
|
chevronRight: mdiChevronRight,
|
||||||
|
chevronLeft: mdiChevronLeft,
|
||||||
|
|
||||||
// Ocr toolbar
|
// Ocr toolbar
|
||||||
selectMode: mdiSelectionDrag,
|
selectMode: mdiSelectionDrag,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('group.admin-group-management') }}
|
{{ $t('group.admin-group-management') }}
|
||||||
</template>
|
</template>
|
||||||
{{ $t('group.admin-group-management-text') }}
|
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
<AppToolbar back />
|
<AppToolbar back />
|
||||||
<v-card-text> {{ $t('group.group-id-value', [group.id]) }} </v-card-text>
|
<v-card-text> {{ $t('group.group-id-value', [group.id]) }} </v-card-text>
|
||||||
@@ -98,6 +97,7 @@ async function handleSubmit() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
group.value = data;
|
group.value = data;
|
||||||
|
alert.success(i18n.t("settings.settings-updated"));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
alert.error(i18n.t("settings.settings-update-failed"));
|
alert.error(i18n.t("settings.settings-update-failed"));
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('household.admin-household-management') }}
|
{{ $t('household.admin-household-management') }}
|
||||||
</template>
|
</template>
|
||||||
{{ $t('household.admin-household-management-text') }}
|
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
<AppToolbar back />
|
<AppToolbar back />
|
||||||
<v-card-text> {{ $t('household.household-id-value', [household.id]) }} </v-card-text>
|
<v-card-text> {{ $t('household.household-id-value', [household.id]) }} </v-card-text>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="narrow-container">
|
<v-container
|
||||||
|
v-if="group"
|
||||||
|
class="narrow-container"
|
||||||
|
>
|
||||||
<BasePageTitle class="mb-5">
|
<BasePageTitle class="mb-5">
|
||||||
<template #header>
|
<template #header>
|
||||||
<v-img
|
<v-img
|
||||||
@@ -14,37 +17,26 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ $t("profile.group-description") }}
|
{{ $t("profile.group-description") }}
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
|
<v-form ref="refGroupEditForm" @submit.prevent="handleSubmit">
|
||||||
<section v-if="group">
|
<v-card variant="outlined" style="border-color: lightgray;">
|
||||||
<BaseCardSectionTitle
|
<v-card-text>
|
||||||
class="mt-10"
|
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
|
||||||
:title="$t('group.group-preferences')"
|
</v-card-text>
|
||||||
/>
|
</v-card>
|
||||||
<div class="mb-6">
|
<div class="d-flex pa-2">
|
||||||
<v-checkbox
|
<BaseButton type="submit" edit class="ml-auto">
|
||||||
v-model="group.preferences.privateGroup"
|
{{ $t("general.update") }}
|
||||||
hide-details
|
</BaseButton>
|
||||||
density="compact"
|
|
||||||
color="primary"
|
|
||||||
:label="$t('group.private-group')"
|
|
||||||
@change="groupActions.updatePreferences()"
|
|
||||||
/>
|
|
||||||
<div class="ml-8">
|
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
|
||||||
{{ $t("group.private-group-description") }}
|
|
||||||
</p>
|
|
||||||
<DocLink
|
|
||||||
class="mt-2"
|
|
||||||
link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</v-form>
|
||||||
</section>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
|
||||||
import { useGroupSelf } from "~/composables/use-groups";
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import type { VForm } from "~/types/auto-forms";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["can-manage-only"],
|
middleware: ["can-manage-only"],
|
||||||
@@ -56,6 +48,22 @@ const i18n = useI18n();
|
|||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: i18n.t("group.group"),
|
title: i18n.t("group.group"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refGroupEditForm = ref<VForm | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!refGroupEditForm.value?.validate() || !group.value?.preferences) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await groupActions.updatePreferences();
|
||||||
|
if (data) {
|
||||||
|
alert.success(i18n.t("settings.settings-updated"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("settings.settings-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|||||||
@@ -183,8 +183,16 @@
|
|||||||
validate-on="blur"
|
validate-on="blur"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
|
<v-checkbox
|
||||||
|
v-model="userCopy.showAnnouncements"
|
||||||
|
hide-details
|
||||||
|
:label="$t('announcements.show-announcements-from-mealie')"
|
||||||
|
color="primary"
|
||||||
|
@change="updateUser"
|
||||||
|
/>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="userCopy.advanced"
|
v-model="userCopy.advanced"
|
||||||
|
hide-details
|
||||||
:label="$t('profile.show-advanced-description')"
|
:label="$t('profile.show-advanced-description')"
|
||||||
color="primary"
|
color="primary"
|
||||||
@change="updateUser"
|
@change="updateUser"
|
||||||
@@ -268,6 +276,7 @@ async function updateUser() {
|
|||||||
admin: userData.admin,
|
admin: userData.admin,
|
||||||
group: userData.group,
|
group: userData.group,
|
||||||
household: userData.household,
|
household: userData.household,
|
||||||
|
showAnnouncements: userData.showAnnouncements,
|
||||||
advanced: userData.advanced,
|
advanced: userData.advanced,
|
||||||
canInvite: userData.canInvite,
|
canInvite: userData.canInvite,
|
||||||
canManage: userData.canManage,
|
canManage: userData.canManage,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
|
||||||
|
declare module "axios" {
|
||||||
|
interface AxiosRequestConfig {
|
||||||
|
suppressAlert?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
|
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
@@ -25,7 +31,7 @@ export default defineNuxtPlugin(() => {
|
|||||||
// Add response interceptor
|
// Add response interceptor
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
if (response?.data?.message) alert.info(response.data.message as string);
|
if (response?.data?.message && !response.config?.suppressAlert) alert.info(response.data.message as string);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default withNuxt({
|
|||||||
"@stylistic": stylistic,
|
"@stylistic": stylistic,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"@stylistic/max-statements-per-line": "off",
|
||||||
"@stylistic/no-tabs": ["error"],
|
"@stylistic/no-tabs": ["error"],
|
||||||
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
plugins: [vue()],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add announcements
|
||||||
|
|
||||||
|
Revision ID: 4395a04f7784
|
||||||
|
Revises: cdc93edaf73d
|
||||||
|
Create Date: 2026-03-27 20:19:07.459075
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "4395a04f7784"
|
||||||
|
down_revision: str | None = "cdc93edaf73d"
|
||||||
|
branch_labels: str | tuple[str, ...] | None = None
|
||||||
|
depends_on: str | tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("show_announcements", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
|
with op.batch_alter_table("household_preferences", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("show_announcements", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
|
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("show_announcements", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
batch_op.add_column(sa.Column("last_read_announcement", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("last_read_announcement")
|
||||||
|
batch_op.drop_column("show_announcements")
|
||||||
|
|
||||||
|
with op.batch_alter_table("household_preferences", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("show_announcements")
|
||||||
|
|
||||||
|
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("show_announcements")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -20,6 +20,7 @@ class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
|
|||||||
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="preferences")
|
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="preferences")
|
||||||
|
|
||||||
private_group: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
private_group: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||||
|
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True)
|
||||||
|
|
||||||
# Deprecated (see household preferences)
|
# Deprecated (see household preferences)
|
||||||
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
|
|||||||
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
||||||
|
|
||||||
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||||
|
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True)
|
||||||
|
|
||||||
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||||
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
login_attemps: Mapped[int | None] = mapped_column(Integer, default=0)
|
login_attemps: Mapped[int | None] = mapped_column(Integer, default=0)
|
||||||
locked_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=None)
|
locked_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=None)
|
||||||
|
|
||||||
# Group Permissions
|
# Announcements
|
||||||
|
show_announcements: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_read_announcement: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# Permissions
|
||||||
can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from mealie.schema._mealie import MealieModel
|
|||||||
|
|
||||||
class UpdateGroupPreferences(MealieModel):
|
class UpdateGroupPreferences(MealieModel):
|
||||||
private_group: bool = True
|
private_group: bool = True
|
||||||
|
show_announcements: bool = True
|
||||||
|
|
||||||
|
|
||||||
class CreateGroupPreferences(UpdateGroupPreferences):
|
class CreateGroupPreferences(UpdateGroupPreferences):
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from mealie.schema._mealie import MealieModel
|
|||||||
|
|
||||||
class UpdateHouseholdPreferences(MealieModel):
|
class UpdateHouseholdPreferences(MealieModel):
|
||||||
private_household: bool = True
|
private_household: bool = True
|
||||||
|
show_announcements: bool = True
|
||||||
|
|
||||||
lock_recipe_edits_from_other_households: bool = True
|
lock_recipe_edits_from_other_households: bool = True
|
||||||
first_day_of_week: int = 0
|
first_day_of_week: int = 0
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ class UserBase(MealieModel):
|
|||||||
household: str | None = None
|
household: str | None = None
|
||||||
advanced: bool = False
|
advanced: bool = False
|
||||||
|
|
||||||
|
# Announcements
|
||||||
|
show_announcements: bool = True
|
||||||
|
last_read_announcement: str | None = None
|
||||||
|
|
||||||
|
# Permissions
|
||||||
can_invite: bool = False
|
can_invite: bool = False
|
||||||
can_manage: bool = False
|
can_manage: bool = False
|
||||||
can_manage_household: bool = False
|
can_manage_household: bool = False
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique
|
|||||||
update_payload = {
|
update_payload = {
|
||||||
"id": unique_user.group_id,
|
"id": unique_user.group_id,
|
||||||
"name": "New Name",
|
"name": "New Name",
|
||||||
"preferences": {"privateGroup": random_bool()},
|
"preferences": {
|
||||||
|
"privateGroup": random_bool(),
|
||||||
|
"showAnnouncements": random_bool(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.put(
|
response = api_client.put(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ def test_admin_update_household(api_client: TestClient, admin_user: TestUser, un
|
|||||||
"privateHousehold": random_bool(),
|
"privateHousehold": random_bool(),
|
||||||
"lockRecipeEditsFromOtherHouseholds": random_bool(),
|
"lockRecipeEditsFromOtherHouseholds": random_bool(),
|
||||||
"firstDayOfWeek": 2,
|
"firstDayOfWeek": 2,
|
||||||
|
"showAnnouncements": random_bool(),
|
||||||
"recipePublic": random_bool(),
|
"recipePublic": random_bool(),
|
||||||
"recipeShowNutrition": random_bool(),
|
"recipeShowNutrition": random_bool(),
|
||||||
"recipeShowAssets": random_bool(),
|
"recipeShowAssets": random_bool(),
|
||||||
|
|||||||
Reference in New Issue
Block a user