mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 13:53:52 +02:00
add mark as read to nav menu
This commit is contained in:
@@ -1,134 +1,155 @@
|
||||
<template>
|
||||
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
||||
<SidebarContent class="pt-2" style="container-type: inline-size">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu v-if="navLayoutReady">
|
||||
<template v-for="item in menuItems" :key="item.index">
|
||||
<SidebarMenuItem v-if="!item.children?.length">
|
||||
<SidebarMenuButton
|
||||
:is-active="activeMenuIndex === item.index"
|
||||
:tooltip="getItemTooltip(item)"
|
||||
@click="handleMenuItemClick(item)">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative">
|
||||
<span
|
||||
v-if="isNavItemNotified(item)"
|
||||
class="notify-dot-not-collapsed"
|
||||
:class="{ '-right-1!': isCollapsed }"
|
||||
aria-hidden="true"></span>
|
||||
</i>
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="item.action === 'direct-access' && !isCollapsed"
|
||||
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
|
||||
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
|
||||
<Kbd>D</Kbd>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem v-else>
|
||||
<DropdownMenu
|
||||
v-if="isCollapsed"
|
||||
:open="collapsedDropdownOpenId === item.index"
|
||||
@update:open="(value) => handleCollapsedDropdownOpenChange(item.index, value)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<SidebarContent class="pt-2" style="container-type: inline-size">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu v-if="navLayoutReady">
|
||||
<template v-for="item in menuItems" :key="item.index">
|
||||
<SidebarMenuItem v-if="!item.children?.length">
|
||||
<SidebarMenuButton
|
||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
||||
:is-active="activeMenuIndex === item.index"
|
||||
:tooltip="getItemTooltip(item)"
|
||||
@click="handleMenuItemClick(item)">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||
><span
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative">
|
||||
<span
|
||||
v-if="isNavItemNotified(item)"
|
||||
class="notify-dot -right-1!"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
class="notify-dot-not-collapsed"
|
||||
:class="{ '-right-1!': isCollapsed }"
|
||||
aria-hidden="true"></span>
|
||||
</i>
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="item.action === 'direct-access' && !isCollapsed"
|
||||
class="nav-shortcut-hint ml-auto inline-flex items-center gap-0.5">
|
||||
<Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
|
||||
<Kbd>D</Kbd>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" class="w-56">
|
||||
<DropdownMenuItem
|
||||
v-for="entry in item.children"
|
||||
:key="entry.index"
|
||||
@select="(event) => handleCollapsedSubmenuSelect(event, entry, item.index)">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-4 items-center justify-center text-base relative"
|
||||
><span
|
||||
v-if="isEntryNotified(entry)"
|
||||
class="notify-dot -right-1! top-0.5!"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<Collapsible
|
||||
v-else
|
||||
class="group/collapsible"
|
||||
:default-open="
|
||||
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)
|
||||
">
|
||||
<template #default="{ open }">
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||
><span
|
||||
v-if="isNavItemNotified(item)"
|
||||
class="notify-dot"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
<SidebarMenuItem v-else>
|
||||
<DropdownMenu
|
||||
v-if="isCollapsed"
|
||||
:open="collapsedDropdownOpenId === item.index"
|
||||
@update:open="
|
||||
(value) => handleCollapsedDropdownOpenChange(item.index, value)
|
||||
">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||
><span
|
||||
v-if="isNavItemNotified(item)"
|
||||
class="notify-dot -right-1!"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" class="w-56">
|
||||
<DropdownMenuItem
|
||||
v-for="entry in item.children"
|
||||
:key="entry.index"
|
||||
@select="
|
||||
(event) =>
|
||||
handleCollapsedSubmenuSelect(event, entry, item.index)
|
||||
">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-4 items-center justify-center text-base relative"
|
||||
><span
|
||||
v-if="isEntryNotified(entry)"
|
||||
class="notify-dot -right-1! top-0.5!"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ChevronRight
|
||||
v-show="!isCollapsed"
|
||||
class="ml-auto transition-transform"
|
||||
:class="open ? 'rotate-90' : ''" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
|
||||
<SidebarMenuSubButton
|
||||
:is-active="activeMenuIndex === entry.index"
|
||||
@click="handleSubmenuClick(entry, item.index)">
|
||||
<Collapsible
|
||||
v-else
|
||||
class="group/collapsible"
|
||||
:default-open="
|
||||
activeMenuIndex &&
|
||||
item.children?.some((e) => e.index === activeMenuIndex)
|
||||
">
|
||||
<template #default="{ open }">
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:is-active="
|
||||
item.children?.some((e) => e.index === activeMenuIndex)
|
||||
"
|
||||
:tooltip="
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-5 items-center justify-center text-base relative"
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||
><span
|
||||
v-if="isEntryNotified(entry)"
|
||||
class="notify-dot -right-0.5!"
|
||||
v-if="isNavItemNotified(item)"
|
||||
class="notify-dot"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
</template>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
|
||||
<ChevronRight
|
||||
v-show="!isCollapsed"
|
||||
class="ml-auto transition-transform"
|
||||
:class="open ? 'rotate-90' : ''" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem
|
||||
v-for="entry in item.children"
|
||||
:key="entry.index">
|
||||
<SidebarMenuSubButton
|
||||
:is-active="activeMenuIndex === entry.index"
|
||||
@click="handleSubmenuClick(entry, item.index)">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-5 items-center justify-center text-base relative"
|
||||
><span
|
||||
v-if="isEntryNotified(entry)"
|
||||
class="notify-dot -right-0.5!"
|
||||
aria-hidden="true"></span
|
||||
></i>
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
</template>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem :disabled="!hasNotifications" @click="clearAllNotifications">
|
||||
{{ t('nav_menu.mark_all_read') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<SidebarFooter class="px-2 py-3">
|
||||
<SidebarMenu>
|
||||
@@ -340,6 +361,7 @@
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronRight, Heart } from 'lucide-vue-next';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
@@ -427,6 +449,8 @@
|
||||
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
|
||||
const uiStore = useUiStore();
|
||||
const { notifiedMenus } = storeToRefs(uiStore);
|
||||
const { clearAllNotifications } = uiStore;
|
||||
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
|
||||
const { directAccessPaste } = useSearchStore();
|
||||
const { logout } = useAuthStore();
|
||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"whats_new": "What's New?",
|
||||
"update_available": "Update available",
|
||||
"update": "Update",
|
||||
"mark_all_read": "Mark All as Read",
|
||||
"custom_nav": {
|
||||
"header": "Customize Navigation",
|
||||
"dialog_title": "Customize Navigation Menu",
|
||||
|
||||
155
src/stores/__tests__/uiNotifications.test.js
Normal file
155
src/stores/__tests__/uiNotifications.test.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../plugin/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||
vi.mock('../../service/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../service/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../service/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
const i18n = actual.createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => i18n.global
|
||||
};
|
||||
});
|
||||
|
||||
import { useUiStore } from '../ui';
|
||||
|
||||
describe('useUiStore - notification methods', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
store = useUiStore();
|
||||
store.notifiedMenus = [];
|
||||
});
|
||||
|
||||
describe('notifyMenu', () => {
|
||||
test('adds a menu key to notifiedMenus', () => {
|
||||
store.notifyMenu('feed');
|
||||
expect(store.notifiedMenus).toContain('feed');
|
||||
});
|
||||
|
||||
test('does not add duplicate keys', () => {
|
||||
store.notifyMenu('feed');
|
||||
store.notifyMenu('feed');
|
||||
expect(
|
||||
store.notifiedMenus.filter((k) => k === 'feed')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('adds multiple different keys', () => {
|
||||
store.notifyMenu('feed');
|
||||
store.notifyMenu('notification');
|
||||
store.notifyMenu('friend-log');
|
||||
expect(store.notifiedMenus).toEqual([
|
||||
'feed',
|
||||
'notification',
|
||||
'friend-log'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNotify', () => {
|
||||
test('removes a specific menu key', () => {
|
||||
store.notifiedMenus = ['feed', 'notification', 'friend-log'];
|
||||
store.removeNotify('notification');
|
||||
expect(store.notifiedMenus).toEqual(['feed', 'friend-log']);
|
||||
});
|
||||
|
||||
test('does nothing when key is not present', () => {
|
||||
store.notifiedMenus = ['feed'];
|
||||
store.removeNotify('notification');
|
||||
expect(store.notifiedMenus).toEqual(['feed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllNotifications', () => {
|
||||
test('clears all notified menus', () => {
|
||||
store.notifiedMenus = ['feed', 'notification', 'friend-log'];
|
||||
store.clearAllNotifications();
|
||||
expect(store.notifiedMenus).toEqual([]);
|
||||
});
|
||||
|
||||
test('works when already empty', () => {
|
||||
store.notifiedMenus = [];
|
||||
store.clearAllNotifications();
|
||||
expect(store.notifiedMenus).toEqual([]);
|
||||
});
|
||||
|
||||
test('menus can be re-added after clearing', () => {
|
||||
store.notifiedMenus = ['feed', 'notification'];
|
||||
store.clearAllNotifications();
|
||||
expect(store.notifiedMenus).toEqual([]);
|
||||
|
||||
store.notifyMenu('friend-log');
|
||||
expect(store.notifiedMenus).toEqual(['friend-log']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,11 @@ export const useUiStore = defineStore('Ui', () => {
|
||||
updateTrayIconNotify();
|
||||
}
|
||||
|
||||
function clearAllNotifications() {
|
||||
notifiedMenus.value = [];
|
||||
updateTrayIconNotify();
|
||||
}
|
||||
|
||||
function updateTrayIconNotify(force = false) {
|
||||
const newState =
|
||||
appearanceSettings.notificationIconDot &&
|
||||
@@ -332,6 +337,7 @@ export const useUiStore = defineStore('Ui', () => {
|
||||
|
||||
notifyMenu,
|
||||
removeNotify,
|
||||
clearAllNotifications,
|
||||
showConsole,
|
||||
updateTrayIconNotify,
|
||||
pushDialogCrumb,
|
||||
|
||||
Reference in New Issue
Block a user