mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-05 14:26:06 +02:00
add mark as read to nav menu
This commit is contained in:
+139
-115
@@ -1,134 +1,155 @@
|
|||||||
<template>
|
<template>
|
||||||
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
||||||
<SidebarContent class="pt-2" style="container-type: inline-size">
|
<ContextMenu>
|
||||||
<SidebarGroup>
|
<ContextMenuTrigger as-child>
|
||||||
<SidebarGroupContent>
|
<SidebarContent class="pt-2" style="container-type: inline-size">
|
||||||
<SidebarMenu v-if="navLayoutReady">
|
<SidebarGroup>
|
||||||
<template v-for="item in menuItems" :key="item.index">
|
<SidebarGroupContent>
|
||||||
<SidebarMenuItem v-if="!item.children?.length">
|
<SidebarMenu v-if="navLayoutReady">
|
||||||
<SidebarMenuButton
|
<template v-for="item in menuItems" :key="item.index">
|
||||||
:is-active="activeMenuIndex === item.index"
|
<SidebarMenuItem v-if="!item.children?.length">
|
||||||
: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>
|
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
:is-active="activeMenuIndex === item.index"
|
||||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
:tooltip="getItemTooltip(item)"
|
||||||
|
@click="handleMenuItemClick(item)">
|
||||||
<i
|
<i
|
||||||
:class="item.icon"
|
:class="item.icon"
|
||||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
class="inline-flex size-6 items-center justify-center text-lg relative">
|
||||||
><span
|
<span
|
||||||
v-if="isNavItemNotified(item)"
|
v-if="isNavItemNotified(item)"
|
||||||
class="notify-dot -right-1!"
|
class="notify-dot-not-collapsed"
|
||||||
aria-hidden="true"></span
|
:class="{ '-right-1!': isCollapsed }"
|
||||||
></i>
|
aria-hidden="true"></span>
|
||||||
|
</i>
|
||||||
<span v-show="!isCollapsed">{{
|
<span v-show="!isCollapsed">{{
|
||||||
item.titleIsCustom ? item.title : t(item.title || '')
|
item.titleIsCustom ? item.title : t(item.title || '')
|
||||||
}}</span>
|
}}</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>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</SidebarMenuItem>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Collapsible
|
<SidebarMenuItem v-else>
|
||||||
v-else
|
<DropdownMenu
|
||||||
class="group/collapsible"
|
v-if="isCollapsed"
|
||||||
:default-open="
|
:open="collapsedDropdownOpenId === item.index"
|
||||||
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)
|
@update:open="
|
||||||
">
|
(value) => handleCollapsedDropdownOpenChange(item.index, value)
|
||||||
<template #default="{ open }">
|
">
|
||||||
<CollapsibleTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')">
|
||||||
<i
|
<i
|
||||||
:class="item.icon"
|
:class="item.icon"
|
||||||
class="inline-flex size-6 items-center justify-center text-lg relative"
|
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||||
><span
|
><span
|
||||||
v-if="isNavItemNotified(item)"
|
v-if="isNavItemNotified(item)"
|
||||||
class="notify-dot"
|
class="notify-dot -right-1!"
|
||||||
aria-hidden="true"></span
|
aria-hidden="true"></span
|
||||||
></i>
|
></i>
|
||||||
<span v-show="!isCollapsed">{{
|
<span v-show="!isCollapsed">{{
|
||||||
item.titleIsCustom ? item.title : t(item.title || '')
|
item.titleIsCustom ? item.title : t(item.title || '')
|
||||||
}}</span>
|
}}</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
|
<Collapsible
|
||||||
v-show="!isCollapsed"
|
v-else
|
||||||
class="ml-auto transition-transform"
|
class="group/collapsible"
|
||||||
:class="open ? 'rotate-90' : ''" />
|
:default-open="
|
||||||
</SidebarMenuButton>
|
activeMenuIndex &&
|
||||||
</CollapsibleTrigger>
|
item.children?.some((e) => e.index === activeMenuIndex)
|
||||||
<CollapsibleContent>
|
">
|
||||||
<SidebarMenuSub>
|
<template #default="{ open }">
|
||||||
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
|
<CollapsibleTrigger as-child>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuButton
|
||||||
:is-active="activeMenuIndex === entry.index"
|
:is-active="
|
||||||
@click="handleSubmenuClick(entry, item.index)">
|
item.children?.some((e) => e.index === activeMenuIndex)
|
||||||
|
"
|
||||||
|
:tooltip="
|
||||||
|
item.titleIsCustom ? item.title : t(item.title || '')
|
||||||
|
">
|
||||||
<i
|
<i
|
||||||
v-if="entry.icon"
|
:class="item.icon"
|
||||||
:class="entry.icon"
|
class="inline-flex size-6 items-center justify-center text-lg relative"
|
||||||
class="inline-flex size-5 items-center justify-center text-base relative"
|
|
||||||
><span
|
><span
|
||||||
v-if="isEntryNotified(entry)"
|
v-if="isNavItemNotified(item)"
|
||||||
class="notify-dot -right-0.5!"
|
class="notify-dot"
|
||||||
aria-hidden="true"></span
|
aria-hidden="true"></span
|
||||||
></i>
|
></i>
|
||||||
<span>{{ t(entry.label) }}</span>
|
<span v-show="!isCollapsed">{{
|
||||||
</SidebarMenuSubButton>
|
item.titleIsCustom ? item.title : t(item.title || '')
|
||||||
</SidebarMenuSubItem>
|
}}</span>
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
<ChevronRight
|
||||||
</template>
|
v-show="!isCollapsed"
|
||||||
</Collapsible>
|
class="ml-auto transition-transform"
|
||||||
</SidebarMenuItem>
|
:class="open ? 'rotate-90' : ''" />
|
||||||
</template>
|
</SidebarMenuButton>
|
||||||
</SidebarMenu>
|
</CollapsibleTrigger>
|
||||||
</SidebarGroupContent>
|
<CollapsibleContent>
|
||||||
</SidebarGroup>
|
<SidebarMenuSub>
|
||||||
</SidebarContent>
|
<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">
|
<SidebarFooter class="px-2 py-3">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -340,6 +361,7 @@
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
|
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { ChevronRight, Heart } from 'lucide-vue-next';
|
import { ChevronRight, Heart } from 'lucide-vue-next';
|
||||||
import { Kbd } from '@/components/ui/kbd';
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
@@ -427,6 +449,8 @@
|
|||||||
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
|
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const { notifiedMenus } = storeToRefs(uiStore);
|
const { notifiedMenus } = storeToRefs(uiStore);
|
||||||
|
const { clearAllNotifications } = uiStore;
|
||||||
|
const hasNotifications = computed(() => notifiedMenus.value.length > 0);
|
||||||
const { directAccessPaste } = useSearchStore();
|
const { directAccessPaste } = useSearchStore();
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"whats_new": "What's New?",
|
"whats_new": "What's New?",
|
||||||
"update_available": "Update available",
|
"update_available": "Update available",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
"mark_all_read": "Mark All as Read",
|
||||||
"custom_nav": {
|
"custom_nav": {
|
||||||
"header": "Customize Navigation",
|
"header": "Customize Navigation",
|
||||||
"dialog_title": "Customize Navigation Menu",
|
"dialog_title": "Customize Navigation Menu",
|
||||||
|
|||||||
@@ -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();
|
updateTrayIconNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAllNotifications() {
|
||||||
|
notifiedMenus.value = [];
|
||||||
|
updateTrayIconNotify();
|
||||||
|
}
|
||||||
|
|
||||||
function updateTrayIconNotify(force = false) {
|
function updateTrayIconNotify(force = false) {
|
||||||
const newState =
|
const newState =
|
||||||
appearanceSettings.notificationIconDot &&
|
appearanceSettings.notificationIconDot &&
|
||||||
@@ -332,6 +337,7 @@ export const useUiStore = defineStore('Ui', () => {
|
|||||||
|
|
||||||
notifyMenu,
|
notifyMenu,
|
||||||
removeNotify,
|
removeNotify,
|
||||||
|
clearAllNotifications,
|
||||||
showConsole,
|
showConsole,
|
||||||
updateTrayIconNotify,
|
updateTrayIconNotify,
|
||||||
pushDialogCrumb,
|
pushDialogCrumb,
|
||||||
|
|||||||
Reference in New Issue
Block a user