Files
VRCX/src/components/NavMenu.vue
2026-03-07 18:41:48 +09:00

795 lines
35 KiB
Vue

<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>
<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>
<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>
<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>
<SidebarFooter class="px-2 py-3">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.help_support')">
<i class="ri-question-line inline-flex size-6 items-center justify-center text-lg" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.help_support') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-56">
<DropdownMenuItem @click="showChangeLogDialog">
<span>{{ t('nav_menu.whats_new') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.resources') }}</DropdownMenuLabel>
<DropdownMenuItem @click="handleSupportLink('wiki')">
<span>{{ t('nav_menu.wiki') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t('nav_menu.get_help') }}</DropdownMenuLabel>
<DropdownMenuItem @click="handleSupportLink('github')">
<span>{{ t('nav_menu.github') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleSupportLink('discord')">
<span>{{ t('nav_menu.discord') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton :tooltip="t('nav_tooltip.toggle_theme')" @click="handleThemeToggle">
<i
:class="isDarkMode ? 'ri-moon-line' : 'ri-sun-line'"
class="inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.toggle_theme') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="t('nav_tooltip.manage')">
<span class="relative inline-flex size-6 items-center justify-center">
<i class="ri-settings-3-line text-lg" />
<span
v-if="pendingVRCXUpdate || pendingVRCXInstall"
class="absolute top-0.5 -right-1 h-1.5 w-1.5 rounded-full bg-red-500"></span>
</span>
<span v-show="!isCollapsed">{{ t('nav_tooltip.manage') }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" class="w-54">
<div class="flex items-center gap-2 px-2 py-1.5">
<img class="h-6 w-6 cursor-pointer" :src="vrcxLogo" alt="VRCX" @click="openGithub" />
<div class="flex min-w-0 flex-col">
<button
type="button"
class="text-left text-sm font-medium truncate flex items-center gap-1"
@click="openGithub">
VRCX
<Heart class="text-primary fill-current stroke-none" />
</button>
<span class="text-xs text-muted-foreground">{{ version }}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="pendingVRCXUpdate || pendingVRCXInstall"
@click="showVRCXUpdateDialog">
<span>{{ t('nav_menu.update_available') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="pendingVRCXUpdate || pendingVRCXInstall" />
<DropdownMenuItem @click="handleSettingsClick">
<span>{{ t('nav_tooltip.settings') }}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
v-for="theme in themes"
:key="theme"
:model-value="themeMode === theme"
indicator-position="right"
@select="handleThemeSelect(theme)">
<span>{{ themeDisplayName(theme) }}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="px-2 py-2 font-normal">
<div class="flex items-center justify-around">
<TooltipWrapper
v-for="theme in themeColors"
:key="theme.key"
side="top"
:content="themeColorDisplayName(theme)"
:delay-duration="600">
<button
type="button"
:disabled="isApplyingThemeColor"
:aria-pressed="currentThemeColor === theme.key"
:aria-label="themeColorDisplayName(theme)"
:title="themeColorDisplayName(theme)"
@click="handleThemeColorSelect(theme)"
class="h-3.5 w-3.5 shrink-0 rounded-sm transition-transform hover:scale-125"
:class="
currentThemeColor === theme.key
? 'ring-1 ring-ring ring-offset-1 ring-offset-background'
: ''
"
:style="{ backgroundColor: theme.swatch }"></button>
</TooltipWrapper>
</div>
</DropdownMenuLabel>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>{{ t('view.settings.appearance.appearance.table_density') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" align="start" class="w-54">
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'standard'"
indicator-position="right"
@select="handleTableDensitySelect('standard')">
<span>{{
t('view.settings.appearance.appearance.table_density_comfortable')
}}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'comfortable'"
indicator-position="right"
@select="handleTableDensitySelect('comfortable')">
<span>{{
t('view.settings.appearance.appearance.table_density_standard')
}}</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
:model-value="tableDensity === 'compact'"
indicator-position="right"
@select="handleTableDensitySelect('compact')">
<span>{{
t('view.settings.appearance.appearance.table_density_compact')
}}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="handleOpenCustomNavDialog">
<span>{{ t('nav_menu.custom_nav.header') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" @click="handleLogoutClick">
<span>{{ t('dialog.user.actions.logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="isCollapsed ? t('nav_tooltip.expand_menu') : t('nav_tooltip.collapse_menu')"
@click="toggleNavCollapse">
<i class="ri-side-bar-line inline-flex size-6 items-center justify-center text-[19px]" />
<span v-show="!isCollapsed">{{ t('nav_tooltip.collapse_menu') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<CustomNavDialog
v-model:visible="customNavDialogVisible"
:layout="navLayout"
:hidden-keys="navHiddenKeys"
:default-layout="defaultNavLayout"
@save="handleCustomNavSave" />
</template>
<script setup>
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronRight, Heart } from 'lucide-vue-next';
import { Kbd } from '@/components/ui/kbd';
import { TooltipWrapper } from '@/components/ui/tooltip';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useThemeColor } from '@/shared/utils/base/ui';
import dayjs from 'dayjs';
import {
useAppearanceSettingsStore,
useAuthStore,
useSearchStore,
useUiStore,
useVRCXUpdaterStore
} from '../stores';
import { getFirstNavRoute, isEntryNotified, normalizeHiddenKeys, sanitizeLayout } from './navMenuUtils';
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
import { openExternalLink } from '../shared/utils';
import configRepository from '../service/config';
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
const { t, locale } = useI18n();
const router = useRouter();
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const getItemTooltip = (item) => {
const label = item.titleIsCustom ? item.title : t(item.title || '');
if (item.action !== 'direct-access') {
return label;
}
return () =>
h('span', { class: 'inline-flex items-center gap-1' }, [
label,
h(Kbd, () => (isMac ? '⌘' : 'Ctrl')),
h(Kbd, () => 'D')
]);
};
const createDefaultNavLayout = () => [
{ type: 'item', key: 'feed' },
{ type: 'item', key: 'friends-locations' },
{ type: 'item', key: 'game-log' },
{ type: 'item', key: 'player-list' },
{ type: 'item', key: 'search' },
{
type: 'folder',
id: 'default-folder-favorites',
nameKey: 'nav_tooltip.favorites',
name: t('nav_tooltip.favorites'),
icon: 'ri-star-line',
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
},
{
type: 'folder',
id: 'default-folder-social',
nameKey: 'nav_tooltip.social',
name: t('nav_tooltip.social'),
icon: 'ri-group-line',
items: ['friend-log', 'friend-list', 'moderation']
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'my-avatars' },
{
type: 'folder',
id: 'default-folder-charts',
nameKey: 'nav_tooltip.charts',
name: t('nav_tooltip.charts'),
icon: 'ri-pie-chart-line',
items: ['charts-instance', 'charts-mutual']
},
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
const VRCXUpdaterStore = useVRCXUpdaterStore();
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
const uiStore = useUiStore();
const { notifiedMenus } = storeToRefs(uiStore);
const { directAccessPaste } = useSearchStore();
const { logout } = useAuthStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const { themeMode, tableDensity, isDarkMode, isNavCollapsed: isCollapsed } = storeToRefs(appearanceSettingsStore);
const navLayout = ref([]);
const navLayoutReady = ref(false);
const collapsedDropdownOpenId = ref(null);
const menuItems = computed(() => {
const items = [];
navLayout.value.forEach((entry) => {
if (entry.type === 'item') {
const definition = navDefinitionMap.get(entry.key);
if (!definition) {
return;
}
items.push({
...definition,
index: definition.key,
title: definition.tooltip || definition.labelKey,
titleIsCustom: false
});
return;
}
if (entry.type === 'folder') {
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
if (folderDefinitions.length === 0) {
return;
}
const folderEntries = folderDefinitions.map((definition) => ({
label: definition.labelKey,
routeName: definition.routeName,
index: definition.key,
icon: definition.icon,
action: definition.action
}));
items.push({
index: entry.id,
icon: entry.icon || DEFAULT_FOLDER_ICON,
title: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
titleIsCustom: true,
children: folderEntries
});
}
});
return items;
});
const activeMenuIndex = computed(() => {
const currentRoute = router.currentRoute.value;
const currentRouteName = currentRoute?.name;
const navKey = currentRoute?.meta?.navKey || currentRouteName;
if (!navKey) {
return getFirstNavRouteLocal(navLayout.value) || 'feed';
}
for (const entry of navLayout.value) {
if (entry.type === 'item' && entry.key === navKey) {
return entry.key;
}
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
return navKey;
}
}
return getFirstNavRouteLocal(navLayout.value) || 'feed';
});
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
const vrcxLogo = new URL('../../images/VRCX.png', import.meta.url).href;
const themes = computed(() => Object.keys(THEME_CONFIG));
const { themeColors, currentThemeColor, isApplyingThemeColor, applyThemeColor, initThemeColor } = useThemeColor();
watch(
() => locale.value,
() => {
if (!navLayoutReady.value) {
return;
}
navLayout.value = navLayout.value.map((entry) => {
if (entry.type === 'folder' && entry.nameKey) {
return {
...entry,
name: t(entry.nameKey)
};
}
return entry;
});
}
);
watch(
() => isCollapsed.value,
(value) => {
if (!value) {
collapsedDropdownOpenId.value = null;
}
}
);
const generateFolderId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `nav-folder-${crypto.randomUUID()}`;
}
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
};
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
return sanitizeLayout(layout, hiddenKeys, navDefinitionMap, navDefinitions, t, generateFolderId);
};
const themeDisplayName = (themeKey) => {
const i18nKey = `view.settings.appearance.appearance.theme_mode_${themeKey}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return THEME_CONFIG[themeKey]?.name ?? themeKey;
};
const themeColorDisplayName = (theme) => {
if (!theme) {
return '';
}
const i18nKey = `view.settings.appearance.theme_color.${theme.key}`;
const translated = t(i18nKey);
if (translated !== i18nKey) {
return translated;
}
return theme.label || theme.key;
};
const handleSettingsClick = () => {
router.push({ name: 'settings' });
};
const handleLogoutClick = () => {
logout();
};
const handleThemeSelect = (theme) => {
appearanceSettingsStore.setThemeMode(theme);
};
const handleThemeToggle = () => {
appearanceSettingsStore.toggleThemeMode();
};
const handleTableDensitySelect = (density) => {
appearanceSettingsStore.setTableDensity(density);
};
const handleThemeColorSelect = async (theme) => {
if (!theme) {
return;
}
await applyThemeColor(theme.key);
};
const openGithub = () => {
openExternalLink('https://github.com/vrcx-team/VRCX');
};
const customNavDialogVisible = ref(false);
const navHiddenKeys = ref([]);
const defaultNavLayout = computed(() => sanitizeLayoutLocal(createDefaultNavLayout(), []));
const saveNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
try {
await configRepository.setString(
'VRCX_customNavMenuLayoutList',
JSON.stringify({
layout,
hiddenKeys: normalizedHiddenKeys
})
);
} catch (error) {
console.error('Failed to save custom nav', error);
}
};
const handleOpenCustomNavDialog = () => {
customNavDialogVisible.value = true;
};
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
await saveNavLayout(sanitized, normalizedHiddenKeys);
customNavDialogVisible.value = false;
};
const loadNavMenuConfig = async () => {
let layoutData = null;
let hiddenKeysData = [];
try {
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
if (storedValue) {
const parsed = JSON.parse(storedValue);
if (Array.isArray(parsed)) {
layoutData = parsed;
} else if (Array.isArray(parsed?.layout)) {
layoutData = parsed.layout;
hiddenKeysData = Array.isArray(parsed.hiddenKeys) ? parsed.hiddenKeys : [];
}
}
} catch (error) {
console.error('Failed to load custom nav', error);
} finally {
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData, navDefinitionMap);
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
const sanitized = sanitizeLayoutLocal(fallbackLayout, normalizedHiddenKeys);
navLayout.value = sanitized;
navHiddenKeys.value = normalizedHiddenKeys;
if (
layoutData?.length &&
(JSON.stringify(sanitized) !== JSON.stringify(fallbackLayout) ||
JSON.stringify(normalizedHiddenKeys) !== JSON.stringify(hiddenKeysData))
) {
await saveNavLayout(sanitized, normalizedHiddenKeys);
}
navLayoutReady.value = true;
navigateToFirstNavEntry();
}
};
const handleSupportLink = (id) => {
const target = links[id];
if (target) {
openExternalLink(target);
}
};
const isNavItemNotified = (item) => {
if (!item) {
return false;
}
if (notifiedMenus.value.includes(item.index)) {
return true;
}
if (item.children?.length) {
return item.children.some((entry) => isEntryNotified(entry, notifiedMenus.value));
}
return false;
};
const triggerNavAction = (entry, navIndex = entry?.index) => {
if (!entry) {
return;
}
if (entry.action === 'direct-access') {
directAccessPaste();
return;
}
if (entry.routeName) {
handleRouteChange(entry.routeName, navIndex);
return;
}
if (entry.path) {
router.push(entry.path);
}
};
const handleRouteChange = (routeName, navIndex = routeName) => {
if (!routeName) {
return;
}
router.push({ name: routeName });
};
/**
*
* @param layout
*/
const getFirstNavRouteLocal = (layout) => getFirstNavRoute(layout, navDefinitionMap);
let hasNavigatedToInitialRoute = false;
const navigateToFirstNavEntry = () => {
if (hasNavigatedToInitialRoute) {
return;
}
const firstRoute = getFirstNavRouteLocal(navLayout.value);
if (!firstRoute) {
return;
}
hasNavigatedToInitialRoute = true;
if (router.currentRoute.value?.name !== firstRoute) {
router.push({ name: firstRoute }).catch(() => {});
}
};
const handleSubmenuClick = (entry, index) => {
const navIndex = index || entry?.index;
triggerNavAction(entry, navIndex);
};
const handleCollapsedDropdownOpenChange = (index, value) => {
collapsedDropdownOpenId.value = value ? index : null;
};
const handleCollapsedSubmenuSelect = (event, entry, index) => {
if (event?.preventDefault) {
event.preventDefault();
}
handleSubmenuClick(entry, index);
};
const handleMenuItemClick = (item) => {
triggerNavAction(item, item?.index);
};
const toggleNavCollapse = () => {
appearanceSettingsStore.toggleNavCollapsed();
};
onMounted(async () => {
await initThemeColor();
await loadNavMenuConfig();
});
</script>
<style scoped>
.notify {
position: relative;
}
.notify-dot {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
background-color: #ef4444;
border-radius: 50%;
transform: translateY(-50%);
}
.notify-dot-not-collapsed {
position: absolute;
top: 4px;
right: 0;
width: 6px;
height: 6px;
background-color: #ef4444;
border-radius: 50%;
transform: translateY(-50%);
}
@container (max-width: 250px) {
.nav-shortcut-hint {
display: none;
}
}
</style>