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"