mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-12 07:55:36 -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>
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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) {
|
||||
groupSelfRef.value.preferences = data;
|
||||
}
|
||||
|
||||
return data || undefined;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default {
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ from mealie.schema._mealie import MealieModel
|
||||
|
||||
class UpdateGroupPreferences(MealieModel):
|
||||
private_group: bool = True
|
||||
show_announcements: bool = True
|
||||
|
||||
|
||||
class CreateGroupPreferences(UpdateGroupPreferences):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user