feat: Announcements (#7431)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson
2026-04-11 08:26:14 -05:00
committed by GitHub
parent 306f2dcfc6
commit d2b0681dbb
32 changed files with 631 additions and 62 deletions

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -1,11 +1,38 @@
<template>
<div v-if="preferences">
<BaseCardSectionTitle :title="$t('group.general-preferences')" />
<v-checkbox
v-model="preferences.privateGroup"
class="mt-n4"
:label="$t('group.private-group')"
/>
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
<div class="mb-6">
<v-checkbox
v-model="preferences.privateGroup"
hide-details
density="compact"
color="primary"
: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>
</template>
@@ -14,5 +41,3 @@ import type { ReadGroupPreferences } from "~/lib/api/types/user";
const preferences = defineModel<ReadGroupPreferences>({ required: true });
</script>
<style lang="scss" scoped></style>

View File

@@ -18,6 +18,20 @@
</p>
</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-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"

View File

@@ -1,5 +1,6 @@
<template>
<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" />
<!-- User Profile -->
<template v-if="loggedIn && sessionUser">
@@ -117,6 +118,24 @@
<!-- Bottom Navigation Links -->
<template #append>
<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">
<template #activator="{ props: hoverProps }">
<v-list-item v-bind="hoverProps" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
@@ -139,8 +158,10 @@
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SidebarLinks } from "~/types/application-types";
import AnnouncementDialog from "~/components/Domain/Announcement/AnnouncementDialog.vue";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useToggleDarkMode } from "~/composables/use-utils";
import { useAnnouncements } from "~/composables/use-announcements";
const props = defineProps({
user: {
@@ -171,6 +192,9 @@ const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undef
const toggleDark = useToggleDarkMode();
const showAnnouncementsDialog = ref(false);
const { announcementsEnabled, newAnnouncements } = useAnnouncements();
const state = reactive({
dropDowns: {} as Record<string, boolean>,
secondarySelected: null as string[] | null,

View File

@@ -35,7 +35,7 @@
</v-toolbar-title>
</v-toolbar>
<div>
<div style="flex: 1 1 auto; min-height: 0; overflow: auto">
<slot v-bind="{ submitEvent }" />
</div>
@@ -52,7 +52,7 @@
emit('cancel');
"
>
{{ $t("general.cancel") }}
{{ cancelText }}
</v-btn>
<v-spacer />
@@ -110,10 +110,16 @@ interface DialogProps {
maxWidth?: number | string | null;
loading?: boolean;
top?: boolean | null;
keepOpen?: boolean;
// submit
submitIcon?: string | null;
submitText?: string;
submitDisabled?: boolean;
keepOpen?: boolean;
// cancel
cancelText?: string;
// actions
canDelete?: boolean;
canConfirm?: boolean;
@@ -135,10 +141,17 @@ const props = withDefaults(defineProps<DialogProps>(), {
maxWidth: null,
loading: false,
top: null,
keepOpen: false,
// submit
submitIcon: null,
submitText: () => useNuxtApp().$i18n.t("general.create"),
submitDisabled: false,
keepOpen: false,
// cancel
cancelText: () => useNuxtApp().$i18n.t("general.cancel"),
// actions
canDelete: false,
canConfirm: false,
canSubmit: false,

View 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,
};
}

View File

@@ -34,6 +34,8 @@ export const useGroupSelf = function () {
if (data) {
groupSelfRef.value.preferences = data;
}
return data || undefined;
},
};

View File

@@ -98,6 +98,7 @@
"dashboard": "Dashboard",
"delete": "Delete",
"disabled": "Disabled",
"done": "Done",
"download": "Download",
"duplicate": "Duplicate",
"edit": "Edit",
@@ -1472,5 +1473,12 @@
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} 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"
}
}

View File

@@ -12,9 +12,4 @@ const routes = {
export class AdminGroupsApi extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate> {
baseRoute: string = routes.adminUsers;
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);
}
}

View File

@@ -1,3 +1,4 @@
import type { AxiosRequestConfig } from "axios";
import type { Recipe } from "../types/recipe";
import type { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
import { type QueryValue, route } from "~/lib/api/base/route";
@@ -44,38 +45,38 @@ export abstract class BaseCRUDAPIReadOnly<ReadType>
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));
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) {
return await this.requests.get<ReadType>(this.itemRoute(itemId));
async getOne(itemId: string | number, config?: AxiosRequestConfig) {
return await this.requests.get<ReadType>(this.itemRoute(itemId), undefined, config);
}
}
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
extends BaseCRUDAPIReadOnly<ReadType>
implements CrudAPIInterface {
async createOne(payload: CreateType) {
return await this.requests.post<ReadType>(this.baseRoute, payload);
async createOne(payload: CreateType, config?: AxiosRequestConfig) {
return await this.requests.post<ReadType>(this.baseRoute, payload, config);
}
async updateOne(itemId: string | number, payload: UpdateType) {
return await this.requests.put<ReadType, UpdateType>(this.itemRoute(itemId), payload);
async updateOne(itemId: string | number, payload: UpdateType, config?: AxiosRequestConfig) {
return await this.requests.put<ReadType, UpdateType>(this.itemRoute(itemId), payload, config);
}
async patchOne(itemId: string, payload: Partial<UpdateType>) {
return await this.requests.patch<ReadType, Partial<UpdateType>>(this.itemRoute(itemId), payload);
async patchOne(itemId: string, payload: Partial<UpdateType>, config?: AxiosRequestConfig) {
return await this.requests.patch<ReadType, Partial<UpdateType>>(this.itemRoute(itemId), payload, config);
}
async deleteOne(itemId: string | number) {
return await this.requests.delete<ReadType>(this.itemRoute(itemId));
async deleteOne(itemId: string | number, config?: AxiosRequestConfig) {
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`, {
name: newName,
});
}, config);
}
}

View File

@@ -19,6 +19,7 @@ export type SupportedMigrations =
export interface CreateGroupPreferences {
privateGroup?: boolean;
showAnnouncements?: boolean;
groupId: string;
}
export interface DataMigrationCreate {
@@ -31,6 +32,7 @@ export interface GroupAdminUpdate {
}
export interface UpdateGroupPreferences {
privateGroup?: boolean;
showAnnouncements?: boolean;
}
export interface GroupDataExport {
id: string;
@@ -49,6 +51,7 @@ export interface GroupStorage {
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
showAnnouncements?: boolean;
groupId: string;
id: string;
}

View File

@@ -15,6 +15,7 @@ export interface CreateGroupRecipeAction {
}
export interface CreateHouseholdPreferences {
privateHousehold?: boolean;
showAnnouncements?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
@@ -199,6 +200,7 @@ export interface HouseholdInDB {
}
export interface ReadHouseholdPreferences {
privateHousehold?: boolean;
showAnnouncements?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
@@ -276,6 +278,7 @@ export interface SaveGroupRecipeAction {
}
export interface SaveHouseholdPreferences {
privateHousehold?: boolean;
showAnnouncements?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
@@ -769,6 +772,7 @@ export interface UpdateHouseholdAdmin {
}
export interface UpdateHouseholdPreferences {
privateHousehold?: boolean;
showAnnouncements?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;

View File

@@ -85,6 +85,7 @@ export interface UserSummary {
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
showAnnouncements?: boolean;
groupId: string;
id: string;
}
@@ -122,6 +123,8 @@ export interface PrivateUser {
group: string;
household: string;
advanced?: boolean;
showAnnouncements?: boolean;
lastReadAnnouncement?: string | null;
canInvite?: boolean;
canManage?: boolean;
canManageHousehold?: boolean;
@@ -194,6 +197,8 @@ export interface UserBase {
group?: string | null;
household?: string | null;
advanced?: boolean;
showAnnouncements?: boolean;
lastReadAnnouncement?: string | null;
canInvite?: boolean;
canManage?: boolean;
canManageHousehold?: boolean;
@@ -209,6 +214,8 @@ export interface UserIn {
group?: string | null;
household?: string | null;
advanced?: boolean;
showAnnouncements?: boolean;
lastReadAnnouncement?: string | null;
canInvite?: boolean;
canManage?: boolean;
canManageHousehold?: boolean;
@@ -225,6 +232,8 @@ export interface UserOut {
group: string;
household: string;
advanced?: boolean;
showAnnouncements?: boolean;
lastReadAnnouncement?: string | null;
canInvite?: boolean;
canManage?: boolean;
canManageHousehold?: boolean;

View File

@@ -20,6 +20,7 @@ import {
mdiBookOutline,
mdiBowlMixOutline,
mdiBroom,
mdiBullhornVariant,
mdiCalendar,
mdiCalendarMinus,
mdiCalendarMultiselect,
@@ -36,6 +37,7 @@ import {
mdiChefHat,
mdiChevronDown,
mdiChevronRight,
mdiChevronLeft,
mdiClipboardCheck,
mdiClockTimeFourOutline,
mdiClose,
@@ -144,6 +146,7 @@ import {
mdiTestTube,
mdiText,
mdiTextBoxOutline,
mdiTextBoxCheckOutline,
mdiTimelineText,
mdiTimerSand,
mdiTools,
@@ -157,6 +160,7 @@ import {
mdiWindowClose,
mdiWrench,
mdiHandWaveOutline,
} from "@mdi/js";
export const icons = {
@@ -184,6 +188,7 @@ export const icons = {
bellAlert: mdiBellAlert,
bellPlus: mdiBellPlus,
broom: mdiBroom,
bullhornVariant: mdiBullhornVariant,
calendar: mdiCalendar,
calendarMinus: mdiCalendarMinus,
calendarMultiselect: mdiCalendarMultiselect,
@@ -277,6 +282,7 @@ export const icons = {
sortClockDescending: mdiSortClockDescending,
star: mdiStar,
testTube: mdiTestTube,
textBoxCheckOutline: mdiTextBoxCheckOutline,
timelineText: mdiTimelineText,
tools: mdiTools,
potSteam: mdiPotSteamOutline,
@@ -327,6 +333,7 @@ export const icons = {
slotMachine: mdiSlotMachine,
chevronDown: mdiChevronDown,
chevronRight: mdiChevronRight,
chevronLeft: mdiChevronLeft,
// Ocr toolbar
selectMode: mdiSelectionDrag,

View File

@@ -15,7 +15,6 @@
<template #title>
{{ $t('group.admin-group-management') }}
</template>
{{ $t('group.admin-group-management-text') }}
</BasePageTitle>
<AppToolbar back />
<v-card-text> {{ $t('group.group-id-value', [group.id]) }} </v-card-text>
@@ -98,6 +97,7 @@ async function handleSubmit() {
window.location.reload();
}
group.value = data;
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));

View File

@@ -15,7 +15,6 @@
<template #title>
{{ $t('household.admin-household-management') }}
</template>
{{ $t('household.admin-household-management-text') }}
</BasePageTitle>
<AppToolbar back />
<v-card-text> {{ $t('household.household-id-value', [household.id]) }} </v-card-text>

View File

@@ -1,5 +1,8 @@
<template>
<v-container class="narrow-container">
<v-container
v-if="group"
class="narrow-container"
>
<BasePageTitle class="mb-5">
<template #header>
<v-img
@@ -14,37 +17,26 @@
</template>
{{ $t("profile.group-description") }}
</BasePageTitle>
<section v-if="group">
<BaseCardSectionTitle
class="mt-10"
:title="$t('group.group-preferences')"
/>
<div class="mb-6">
<v-checkbox
v-model="group.preferences.privateGroup"
hide-details
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>
<v-form ref="refGroupEditForm" @submit.prevent="handleSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</section>
</v-form>
</v-container>
</template>
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import { useGroupSelf } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
middleware: ["can-manage-only"],
@@ -56,6 +48,22 @@ const i18n = useI18n();
useSeoMeta({
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>
<style lang="css">

View File

@@ -183,8 +183,16 @@
validate-on="blur"
persistent-hint
/>
<v-checkbox
v-model="userCopy.showAnnouncements"
hide-details
:label="$t('announcements.show-announcements-from-mealie')"
color="primary"
@change="updateUser"
/>
<v-checkbox
v-model="userCopy.advanced"
hide-details
:label="$t('profile.show-advanced-description')"
color="primary"
@change="updateUser"
@@ -268,6 +276,7 @@ async function updateUser() {
admin: userData.admin,
group: userData.group,
household: userData.household,
showAnnouncements: userData.showAnnouncements,
advanced: userData.advanced,
canInvite: userData.canInvite,
canManage: userData.canManage,

View File

@@ -1,6 +1,12 @@
import axios from "axios";
import { alert } from "~/composables/use-toast";
declare module "axios" {
interface AxiosRequestConfig {
suppressAlert?: boolean;
}
}
export default defineNuxtPlugin(() => {
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
const axiosInstance = axios.create({
@@ -25,7 +31,7 @@ export default defineNuxtPlugin(() => {
// Add response interceptor
axiosInstance.interceptors.response.use(
(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;
},
(error) => {

View File

@@ -7,6 +7,7 @@ export default withNuxt({
"@stylistic": stylistic,
},
rules: {
"@stylistic/max-statements-per-line": "off",
"@stylistic/no-tabs": ["error"],
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"@typescript-eslint/no-explicit-any": "off",

View File

@@ -1,6 +1,8 @@
import path from "path";
import vue from "@vitejs/plugin-vue";
export default {
plugins: [vue()],
test: {
globals: true,
environment: "jsdom",

View File

@@ -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 ###

View File

@@ -20,6 +20,7 @@ class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="preferences")
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)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)

View File

@@ -22,6 +22,8 @@ class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
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)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)

View File

@@ -68,7 +68,11 @@ class User(SqlAlchemyBase, BaseMixins):
login_attemps: Mapped[int | None] = mapped_column(Integer, default=0)
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: Mapped[bool | None] = mapped_column(Boolean, default=False)
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)

View File

@@ -7,6 +7,7 @@ from mealie.schema._mealie import MealieModel
class UpdateGroupPreferences(MealieModel):
private_group: bool = True
show_announcements: bool = True
class CreateGroupPreferences(UpdateGroupPreferences):

View File

@@ -9,6 +9,8 @@ from mealie.schema._mealie import MealieModel
class UpdateHouseholdPreferences(MealieModel):
private_household: bool = True
show_announcements: bool = True
lock_recipe_edits_from_other_households: bool = True
first_day_of_week: int = 0

View File

@@ -116,6 +116,11 @@ class UserBase(MealieModel):
household: str | None = None
advanced: bool = False
# Announcements
show_announcements: bool = True
last_read_announcement: str | None = None
# Permissions
can_invite: bool = False
can_manage: bool = False
can_manage_household: bool = False

View File

@@ -59,7 +59,10 @@ def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique
update_payload = {
"id": unique_user.group_id,
"name": "New Name",
"preferences": {"privateGroup": random_bool()},
"preferences": {
"privateGroup": random_bool(),
"showAnnouncements": random_bool(),
},
}
response = api_client.put(

View File

@@ -44,6 +44,7 @@ def test_admin_update_household(api_client: TestClient, admin_user: TestUser, un
"privateHousehold": random_bool(),
"lockRecipeEditsFromOtherHouseholds": random_bool(),
"firstDayOfWeek": 2,
"showAnnouncements": random_bool(),
"recipePublic": random_bool(),
"recipeShowNutrition": random_bool(),
"recipeShowAssets": random_bool(),