custom fonts

This commit is contained in:
pa
2026-01-17 19:42:55 +09:00
committed by Natsumi
parent f7a214951d
commit 5e5abc1141
29 changed files with 251 additions and 153 deletions
+11 -1
View File
@@ -7,6 +7,7 @@
"name": "VRCX", "name": "VRCX",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource-variable/noto-sans": "^5.2.10",
"hazardous": "^0.3.0", "hazardous": "^0.3.0",
"node-api-dotnet": "^0.9.18" "node-api-dotnet": "^0.9.18"
}, },
@@ -2075,6 +2076,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/noto-sans": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans/-/noto-sans-5.2.10.tgz",
"integrity": "sha512-wyFgKkFu7jki5kEL8qv7avjQ8rxHX0J/nhLWvbR9T0hOH1HRKZEvb9EW9lMjZfWHHfEzKkYf5J+NadwgCS7TXA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-jp": { "node_modules/@fontsource-variable/noto-sans-jp": {
"version": "5.2.10", "version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.10.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.10.tgz",
@@ -18487,4 +18497,4 @@
} }
} }
} }
} }
+2 -1
View File
@@ -178,7 +178,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/noto-sans": "^5.2.10",
"hazardous": "^0.3.0", "hazardous": "^0.3.0",
"node-api-dotnet": "^0.9.18" "node-api-dotnet": "^0.9.18"
} }
} }
+1
View File
@@ -20,6 +20,7 @@ html {
padding: 10px; padding: 10px;
overflow: hidden auto; overflow: hidden auto;
box-sizing: border-box; box-sizing: border-box;
background: var(--sidebar);
height: 100%; height: 100%;
} }
+4 -5
View File
@@ -1,17 +1,16 @@
<template> <template>
<div @click="confirm" class="cursor-pointer w-fit align-top flex items-center"> <div @click="confirm" class="cursor-pointer w-fit align-top flex items-center">
<span v-if="avatarType" :class="color" class="mr-1.5"> <span>{{ avatarName }}</span>
<Lock v-if="avatarType === '(own)'" class="h-4 w-4" /> <span v-if="avatarType === '(own)'" :class="color" class="mx-1">
<Unlock v-else-if="avatarType === '(public)'" class="h-4 w-4" /> <Lock v-if="avatarType" class="h-4 w-4" />
</span> </span>
<span class="mr-2">{{ avatarName }}</span>
<span v-if="avatarTags" style="font-size: 12px">{{ avatarTags }}</span> <span v-if="avatarTags" style="font-size: 12px">{{ avatarTags }}</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Lock, Unlock } from 'lucide-vue-next';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { Lock } from 'lucide-vue-next';
import { useAvatarStore } from '../stores'; import { useAvatarStore } from '../stores';
+3 -2
View File
@@ -132,9 +132,10 @@
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<button <button
type="button" type="button"
class="text-left text-sm font-medium truncate" class="text-left text-sm font-medium truncate flex items-center gap-1"
@click="openGithub"> @click="openGithub">
VRCX VRCX
<Heart class="text-primary fill-current stroke-none" />
</button> </button>
<span class="text-xs text-muted-foreground">{{ version }}</span> <span class="text-xs text-muted-foreground">{{ version }}</span>
</div> </div>
@@ -288,7 +289,7 @@
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'; import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronRight } from 'lucide-vue-next'; import { ChevronRight, Heart } from 'lucide-vue-next';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -82,12 +82,22 @@
><Apple class="h-4 w-4 text-[#8e8e93]" /> ><Apple class="h-4 w-4 text-[#8e8e93]" />
<span <span
v-if="avatarDialog.platformInfo.ios" v-if="avatarDialog.platformInfo.ios"
:class="['x-grey', 'x-tag-border-left', 'text-[#8e8e93]', 'border-[#8e8e93]']" :class="[
'x-grey',
'x-tag-border-left',
'text-[#8e8e93]',
'border-[#8e8e93]'
]"
>{{ avatarDialog.platformInfo.ios.performanceRating }}</span >{{ avatarDialog.platformInfo.ios.performanceRating }}</span
> >
<span <span
v-if="avatarDialog.bundleSizes['ios']" v-if="avatarDialog.bundleSizes['ios']"
:class="['x-grey', 'x-tag-border-left', 'text-[#8e8e93]', 'border-[#8e8e93]']" :class="[
'x-grey',
'x-tag-border-left',
'text-[#8e8e93]',
'border-[#8e8e93]'
]"
>{{ avatarDialog.bundleSizes['ios'].fileSize }}</span >{{ avatarDialog.bundleSizes['ios'].fileSize }}</span
> >
</Badge> </Badge>
@@ -491,14 +501,14 @@
</template> </template>
<template #JSON> <template #JSON>
<Button <Button
class="rounded-full h-6 w-6 mr-2" class="rounded-full mr-2"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="refreshAvatarDialogTreeData()"> @click="refreshAvatarDialogTreeData()">
<RefreshCw /> <RefreshCw />
</Button> </Button>
<Button <Button
class="rounded-full h-6 w-6" class="rounded-full"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="downloadAndSaveJson(avatarDialog.id, avatarDialog.ref)"> @click="downloadAndSaveJson(avatarDialog.id, avatarDialog.ref)">
@@ -1139,14 +1139,14 @@
</template> </template>
<template #JSON> <template #JSON>
<Button <Button
class="rounded-full h-6 w-6 mr-2" class="rounded-full mr-2"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="refreshGroupDialogTreeData()"> @click="refreshGroupDialogTreeData()">
<RefreshCw /> <RefreshCw />
</Button> </Button>
<Button <Button
class="rounded-full h-6 w-6" class="rounded-full"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="downloadAndSaveJson(groupDialog.id, groupDialog.ref)"> @click="downloadAndSaveJson(groupDialog.id, groupDialog.ref)">
@@ -1,17 +1,13 @@
<template> <template>
<Dialog v-model:open="groupMemberModeration.visible"> <Dialog v-model:open="groupMemberModeration.visible">
<DialogContent class="x-dialog max-w-none w-[90vw]"> <DialogContent class="x-dialog max-w-none sm:min-w-[90vw] sm:max-w-[90vw] sm:min-h-[90vh] sm:max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ t('dialog.group_member_moderation.header') }}</DialogTitle> <DialogTitle>{{ t('dialog.group_member_moderation.header') }}</DialogTitle>
</DialogHeader> </DialogHeader>
<div> <div>
<h3>{{ groupMemberModeration.groupRef.name }}</h3> <h3>{{ groupMemberModeration.groupRef.name }}</h3>
<TabsUnderline <TabsUnderline default-value="members" :items="groupModerationTabs" :unmount-on-hide="false">
default-value="members"
:items="groupModerationTabs"
:unmount-on-hide="false"
style="height: 100%">
<template #members> <template #members>
<div style="margin-top: 10px"> <div style="margin-top: 10px">
<Button <Button
@@ -54,10 +50,8 @@
) )
" "
@click.stop> @click.stop>
<span> {{ t(memberSortOrder.name) }}
{{ t(memberSortOrder.name) }} <ArrowDown style="margin-left: 5px" />
<ArrowDown style="margin-left: 5px" />
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
@@ -96,10 +90,8 @@
) )
" "
@click.stop> @click.stop>
<span> {{ t(memberFilter.name) }}
{{ t(memberFilter.name) }} <ArrowDown style="margin-left: 5px" />
<ArrowDown style="margin-left: 5px" />
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
@@ -193,9 +193,7 @@
(userDialog.representedGroup && userDialog.representedGroup.isRepresenting) (userDialog.representedGroup && userDialog.representedGroup.isRepresenting)
" "
class="extra"> class="extra">
<div <div style="display: inline-block; flex: none; margin-right: 5px">
style="display: inline-block; flex: none; margin-right: 5px">
<Avatar <Avatar
class="x-link size-15! rounded-lg!" class="x-link size-15! rounded-lg!"
:style="{ :style="{
@@ -1055,10 +1053,7 @@
</Select> </Select>
</div> </div>
</div> </div>
<div <div class="x-friend-list" style="margin-top: 10px; min-height: 60px">
class="x-friend-list"
style="margin-top: 10px; min-height: 60px">
<div <div
v-for="world in userDialog.worlds" v-for="world in userDialog.worlds"
:key="world.id" :key="world.id"
@@ -1092,7 +1087,6 @@
v-model="favoriteWorldsTab" v-model="favoriteWorldsTab"
:items="favoriteWorldTabs" :items="favoriteWorldTabs"
:unmount-on-hide="false" :unmount-on-hide="false"
class="zero-margin-tabs" class="zero-margin-tabs"
style="margin-top: 10px; height: 50vh"> style="margin-top: 10px; height: 50vh">
<template <template
@@ -1257,14 +1251,14 @@
<template #JSON> <template #JSON>
<Button <Button
class="rounded-full h-6 w-6 mr-2" class="rounded-full mr-2"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="refreshUserDialogTreeData()"> @click="refreshUserDialogTreeData()">
<RefreshCw /> <RefreshCw />
</Button> </Button>
<Button <Button
class="rounded-full h-6 w-6" class="rounded-full"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="downloadAndSaveJson(userDialog.id, userDialog.ref)"> @click="downloadAndSaveJson(userDialog.id, userDialog.ref)">
@@ -93,7 +93,12 @@
<Apple class="h-4 w-4 text-[#8e8e93]" /> <Apple class="h-4 w-4 text-[#8e8e93]" />
><span ><span
v-if="worldDialog.bundleSizes['ios']" v-if="worldDialog.bundleSizes['ios']"
:class="['x-grey', 'x-tag-border-left', 'text-[#8e8e93]', 'border-[#8e8e93]']"> :class="[
'x-grey',
'x-tag-border-left',
'text-[#8e8e93]',
'border-[#8e8e93]'
]">
{{ worldDialog.bundleSizes['ios'].fileSize }} {{ worldDialog.bundleSizes['ios'].fileSize }}
</span> </span>
</Badge> </Badge>
@@ -680,14 +685,14 @@
</template> </template>
<template #JSON> <template #JSON>
<Button <Button
class="rounded-full h-6 w-6 mr-2" class="rounded-full mr-2"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="refreshWorldDialogTreeData()"> @click="refreshWorldDialogTreeData()">
<RefreshCw /> <RefreshCw />
</Button> </Button>
<Button <Button
class="rounded-full h-6 w-6" class="rounded-full"
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
@click="downloadAndSaveJson(worldDialog.id, worldDialog.ref)"> @click="downloadAndSaveJson(worldDialog.id, worldDialog.ref)">
+2 -1
View File
@@ -65,7 +65,8 @@
:class=" :class="
cn( cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class props.class,
'max-h-[85vh] overflow-y-auto scrollbar-hidden'
) )
"> ">
<slot /> <slot />
+4
View File
@@ -552,6 +552,10 @@
"language": "Language", "language": "Language",
"bio_language": "Target Language", "bio_language": "Target Language",
"theme_mode": "Theme", "theme_mode": "Theme",
"font_family": "Font",
"font_family_inter": "Inter",
"font_family_noto_sans": "Noto Sans",
"font_family_harmonyos_sans": "HarmonyOS Sans",
"theme_mode_system": "System", "theme_mode_system": "System",
"theme_mode_light": "Light", "theme_mode_light": "Light",
"theme_mode_dark": "Dark", "theme_mode_dark": "Dark",
+21
View File
@@ -0,0 +1,21 @@
const APP_FONT_DEFAULT_KEY = 'noto_sans';
const APP_FONT_CONFIG = Object.freeze({
inter: {
cssName: "'Inter'",
link: null
},
noto_sans: {
cssName: "'Noto Sans Variable'",
link: null
},
harmonyos_sans: {
cssName: "'HarmonyOS Sans'",
cssImport:
"@import url('https://fonts.cdnfonts.com/css/harmonyos-sans');"
}
});
const APP_FONT_FAMILIES = Object.freeze(Object.keys(APP_FONT_CONFIG));
export { APP_FONT_CONFIG, APP_FONT_DEFAULT_KEY, APP_FONT_FAMILIES };
+1
View File
@@ -8,6 +8,7 @@ export * from './instance';
export * from './world'; export * from './world';
export * from './moderation'; export * from './moderation';
export * from './themes'; export * from './themes';
export * from './fonts';
export * from './link'; export * from './link';
export * from './ui'; export * from './ui';
export * from './accessType'; export * from './accessType';
+54 -1
View File
@@ -2,7 +2,12 @@ import { ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { THEME_COLORS, THEME_CONFIG } from '../../constants'; import {
APP_FONT_CONFIG,
APP_FONT_DEFAULT_KEY,
THEME_COLORS,
THEME_CONFIG
} from '../../constants';
import { i18n } from '../../../plugin/i18n'; import { i18n } from '../../../plugin/i18n';
import { router } from '../../../plugin/router'; import { router } from '../../../plugin/router';
import { textToHex } from './string'; import { textToHex } from './string';
@@ -14,6 +19,8 @@ const THEME_COLOR_STORAGE_KEY = 'VRCX_themeColor';
const THEME_COLOR_STYLE_ID = 'app-theme-color-style'; const THEME_COLOR_STYLE_ID = 'app-theme-color-style';
const DEFAULT_THEME_COLOR_KEY = 'default'; const DEFAULT_THEME_COLOR_KEY = 'default';
const APP_FONT_LINK_ATTR = 'data-app-font';
const themeColors = THEME_COLORS.map((theme) => ({ const themeColors = THEME_COLORS.map((theme) => ({
...theme, ...theme,
href: theme.file href: theme.file
@@ -132,6 +139,51 @@ function applyThemeFonts(themeKey, fontLinks = []) {
}); });
} }
function resolveAppFontFamily(fontKey) {
const normalized = String(fontKey || '')
.trim()
.toLowerCase();
if (APP_FONT_CONFIG[normalized]) {
return { key: normalized, ...APP_FONT_CONFIG[normalized] };
}
return {
key: APP_FONT_DEFAULT_KEY,
...APP_FONT_CONFIG[APP_FONT_DEFAULT_KEY]
};
}
function ensureAppFontLinks() {
const head = document.head;
if (!head) {
return;
}
Object.entries(APP_FONT_CONFIG).forEach(([key, config]) => {
if (!config?.cssImport) {
return;
}
const existing = document.querySelector(
`style[${APP_FONT_LINK_ATTR}="${key}"]`
);
if (existing) {
return;
}
const styleEl = document.createElement('style');
styleEl.setAttribute(APP_FONT_LINK_ATTR, key);
styleEl.textContent = config.cssImport;
head.appendChild(styleEl);
});
}
function applyAppFontFamily(fontKey) {
const resolved = resolveAppFontFamily(fontKey);
const root = document.documentElement;
root.style.setProperty('--font-western-primary', resolved.cssName);
ensureAppFontLinks();
return resolved;
}
function changeAppThemeStyle(themeMode) { function changeAppThemeStyle(themeMode) {
if (themeMode === 'system') { if (themeMode === 'system') {
themeMode = systemIsDarkMode() ? 'dark' : 'light'; themeMode = systemIsDarkMode() ? 'dark' : 'light';
@@ -379,6 +431,7 @@ export {
updateTrustColorClasses, updateTrustColorClasses,
refreshCustomCss, refreshCustomCss,
refreshCustomScript, refreshCustomScript,
applyAppFontFamily,
HueToHex, HueToHex,
HSVtoRGB, HSVtoRGB,
formatJsonVars, formatJsonVars,
+30 -2
View File
@@ -5,11 +5,16 @@ import { useRouter } from 'vue-router';
import { import {
HueToHex, HueToHex,
applyAppFontFamily,
changeAppThemeStyle, changeAppThemeStyle,
changeHtmlLangAttribute, changeHtmlLangAttribute,
getThemeMode, getThemeMode,
updateTrustColorClasses updateTrustColorClasses
} from '../../shared/utils/base/ui'; } from '../../shared/utils/base/ui';
import {
APP_FONT_DEFAULT_KEY,
APP_FONT_FAMILIES
} from '../../shared/constants';
import { database } from '../../service/database'; import { database } from '../../service/database';
import { getNameColour } from '../../shared/utils'; import { getNameColour } from '../../shared/utils';
import { languageCodes } from '../../localization'; import { languageCodes } from '../../localization';
@@ -46,6 +51,7 @@ export const useAppearanceSettingsStore = defineStore(
const appLanguage = ref('en'); const appLanguage = ref('en');
const themeMode = ref(''); const themeMode = ref('');
const isDarkMode = ref(false); const isDarkMode = ref(false);
const appFontFamily = ref('inter');
const displayVRCPlusIconsAsAvatar = ref(false); const displayVRCPlusIconsAsAvatar = ref(false);
const hideNicknames = ref(false); const hideNicknames = ref(false);
const showInstanceIdInLocation = ref(false); const showInstanceIdInLocation = ref(false);
@@ -130,7 +136,8 @@ export const useAppearanceSettingsStore = defineStore(
compactTableModeConfig, compactTableModeConfig,
trustColorConfig, trustColorConfig,
notificationIconDotConfig, notificationIconDotConfig,
navIsCollapsedConfig navIsCollapsedConfig,
appFontFamilyConfig
] = await Promise.all([ ] = await Promise.all([
configRepository.getString('VRCX_appLanguage'), configRepository.getString('VRCX_appLanguage'),
configRepository.getBool('displayVRCPlusIconsAsAvatar', true), configRepository.getBool('displayVRCPlusIconsAsAvatar', true),
@@ -185,7 +192,11 @@ export const useAppearanceSettingsStore = defineStore(
JSON.stringify(TRUST_COLOR_DEFAULTS) JSON.stringify(TRUST_COLOR_DEFAULTS)
), ),
configRepository.getBool('VRCX_notificationIconDot', true), configRepository.getBool('VRCX_notificationIconDot', true),
configRepository.getBool('VRCX_navIsCollapsed', true) configRepository.getBool('VRCX_navIsCollapsed', true),
configRepository.getString(
'VRCX_fontFamily',
APP_FONT_DEFAULT_KEY
)
]); ]);
if (!appLanguageConfig) { if (!appLanguageConfig) {
@@ -205,6 +216,8 @@ export const useAppearanceSettingsStore = defineStore(
themeMode.value = initThemeMode; themeMode.value = initThemeMode;
isDarkMode.value = initDarkMode; isDarkMode.value = initDarkMode;
appFontFamily.value = normalizeAppFontFamily(appFontFamilyConfig);
applyAppFontFamily(appFontFamily.value);
displayVRCPlusIconsAsAvatar.value = displayVRCPlusIconsAsAvatar.value =
displayVRCPlusIconsAsAvatarConfig; displayVRCPlusIconsAsAvatarConfig;
@@ -456,6 +469,19 @@ export const useAppearanceSettingsStore = defineStore(
updateTrustColor(undefined, undefined); updateTrustColor(undefined, undefined);
} }
function normalizeAppFontFamily(value) {
return APP_FONT_FAMILIES.includes(value)
? value
: APP_FONT_DEFAULT_KEY;
}
function setAppFontFamily(value) {
const normalized = normalizeAppFontFamily(value);
appFontFamily.value = normalized;
configRepository.setString('VRCX_fontFamily', normalized);
applyAppFontFamily(normalized);
}
function setDisplayVRCPlusIconsAsAvatar() { function setDisplayVRCPlusIconsAsAvatar() {
displayVRCPlusIconsAsAvatar.value = displayVRCPlusIconsAsAvatar.value =
!displayVRCPlusIconsAsAvatar.value; !displayVRCPlusIconsAsAvatar.value;
@@ -820,6 +846,7 @@ export const useAppearanceSettingsStore = defineStore(
appLanguage, appLanguage,
themeMode, themeMode,
isDarkMode, isDarkMode,
appFontFamily,
displayVRCPlusIconsAsAvatar, displayVRCPlusIconsAsAvatar,
hideNicknames, hideNicknames,
showInstanceIdInLocation, showInstanceIdInLocation,
@@ -886,6 +913,7 @@ export const useAppearanceSettingsStore = defineStore(
applyTableDensity, applyTableDensity,
setNavCollapsed, setNavCollapsed,
toggleNavCollapsed, toggleNavCollapsed,
setAppFontFamily,
setThemeMode setThemeMode
}; };
} }
+1
View File
@@ -1,4 +1,5 @@
@import '@fontsource-variable/inter'; @import '@fontsource-variable/inter';
@import '@fontsource-variable/noto-sans';
@import '@fontsource-variable/noto-sans-jp'; @import '@fontsource-variable/noto-sans-jp';
@import '@fontsource-variable/noto-sans-kr'; @import '@fontsource-variable/noto-sans-kr';
+16 -2
View File
@@ -48,9 +48,10 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--font-western-primary: 'Inter';
--font-western: --font-western:
'ellipsis-font', -apple-system, 'Inter', 'Segoe UI', 'Roboto', 'Ubuntu', 'ellipsis-font', -apple-system, var(--font-western-primary), 'Segoe UI',
'Cantarell', 'DejaVu Sans', sans-serif; 'Roboto', 'Ubuntu', 'Cantarell', 'DejaVu Sans', sans-serif;
--font-symbol: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; --font-symbol: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
--font-fallback-cjk: sans-serif; --font-fallback-cjk: sans-serif;
--font-primary-cjk: --font-primary-cjk:
@@ -171,6 +172,19 @@
} }
} }
@layer utilities {
.scrollbar-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
}
[data-slot='table-header'], [data-slot='table-header'],
[data-slot='table-header'] [data-slot='table-row'], [data-slot='table-header'] [data-slot='table-row'],
[data-slot='table-head'] { [data-slot='table-head'] {
-2
View File
@@ -1,5 +1,3 @@
/* Noty.js */
.noty_layout { .noty_layout {
word-break: break-all; word-break: break-all;
} }
@@ -7,7 +7,7 @@
</div> </div>
<div class="favorites-search-card__detail"> <div class="favorites-search-card__detail">
<div class="favorites-search-card__title"> <div class="favorites-search-card__title">
<span class="name">{{ localFavFakeRef.name }}</span> <span class="name text-sm">{{ localFavFakeRef.name }}</span>
<span class="favorites-search-card__badges"> <span class="favorites-search-card__badges">
<TooltipWrapper <TooltipWrapper
v-if="favorite.deleted" v-if="favorite.deleted"
@@ -23,7 +23,7 @@
</TooltipWrapper> </TooltipWrapper>
</span> </span>
</div> </div>
<span class="text-xs">{{ localFavFakeRef.authorName }}</span> <span class="text-xs text-gray-600">{{ localFavFakeRef.authorName }}</span>
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
@@ -15,7 +15,7 @@
:traveling="favorite.ref.travelingToLocation" :traveling="favorite.ref.travelingToLocation"
:link="false" /> :link="false" />
</div> </div>
<span v-else class="text-xs">{{ favorite.ref.statusDescription }}</span> <span v-else class="text-xs text-gray-600">{{ favorite.ref.statusDescription }}</span>
</div> </div>
</div> </div>
<div class="favorites-search-card__actions"> <div class="favorites-search-card__actions">
@@ -15,7 +15,7 @@
</div> </div>
<div class="favorites-search-card__detail" v-once> <div class="favorites-search-card__detail" v-once>
<div class="favorites-search-card__title"> <div class="favorites-search-card__title">
<span class="name">{{ props.favorite.ref.name }}</span> <span class="name text-sm">{{ props.favorite.ref.name }}</span>
<span <span
v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'" v-if="favorite.deleted || favorite.ref.releaseStatus === 'private'"
class="favorites-search-card__badges"> class="favorites-search-card__badges">
@@ -29,7 +29,7 @@
class="h-4 w-4" /> class="h-4 w-4" />
</span> </span>
</div> </div>
<span class="text-xs"> <span class="text-xs text-gray-600">
{{ props.favorite.ref.authorName }} {{ props.favorite.ref.authorName }}
<template v-if="props.favorite.ref.occupants"> ({{ props.favorite.ref.occupants }}) </template> <template v-if="props.favorite.ref.occupants"> ({{ props.favorite.ref.occupants }}) </template>
</span> </span>
+2 -2
View File
@@ -31,7 +31,7 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
size: 100, size: 120,
header: ({ column }) => ( header: ({ column }) => (
<Button <Button
variant="ghost" variant="ghost"
@@ -65,7 +65,7 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
{ {
accessorKey: 'type', accessorKey: 'type',
size: 120, size: 160,
header: () => t('table.friendLog.type'), header: () => t('table.friendLog.type'),
cell: ({ row }) => { cell: ({ row }) => {
const type = row.getValue('type'); const type = row.getValue('type');
+29 -84
View File
@@ -60,40 +60,29 @@
</div> </div>
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll"> <ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
<div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualListStyle"> <div v-if="virtualRows.length" class="friend-view__virtual" :style="virtualListStyle">
<div class="friend-view__virtual-spacer" :style="virtualSpacerStyle"> <div v-for="row in virtualRows" :key="String(row.key)" class="friend-view__virtual-row">
<div <template v-if="row.type === 'header'">
v-for="vRow in virtualItems" <header class="friend-view__instance-header">
:key="String(virtualRows[vRow.index]?.key ?? vRow.key)" <Location class="text-xs" :location="row.instanceId" style="display: inline" />
class="friend-view__virtual-row" <span class="friend-view__instance-count">{{ row.count }}</span>
:data-index="vRow.index" </header>
:ref="(el) => onVirtualRowRef(el)" </template>
:style="virtualRowStyle(vRow.start)">
<template v-if="virtualRows[vRow.index]?.type === 'header'">
<header class="friend-view__instance-header">
<Location
class="text-xs"
:location="virtualRows[vRow.index].instanceId"
style="display: inline" />
<span class="friend-view__instance-count">{{ virtualRows[vRow.index].count }}</span>
</header>
</template>
<template v-else-if="virtualRows[vRow.index]?.type === 'divider'"> <template v-else-if="row.type === 'divider'">
<div class="friend-view__divider"><span class="friend-view__divider-text"></span></div> <div class="friend-view__divider"><span class="friend-view__divider-text"></span></div>
</template> </template>
<template v-else> <template v-else>
<div class="friend-view__row"> <div class="friend-view__row">
<FriendLocationCard <FriendLocationCard
v-for="item in virtualRows[vRow.index]?.items ?? []" v-for="item in row.items ?? []"
:key="item.key" :key="item.key"
:friend="item.friend" :friend="item.friend"
:card-scale="cardScale" :card-scale="cardScale"
:card-spacing="cardSpacing" :card-spacing="cardSpacing"
:display-instance-info="item.displayInstanceInfo" /> :display-instance-info="item.displayInstanceInfo" />
</div> </div>
</template> </template>
</div>
</div> </div>
</div> </div>
<div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div> <div v-else class="friend-view__empty">{{ t('view.friends_locations.no_matching_friends') }}</div>
@@ -113,7 +102,6 @@
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
import { Slider } from '../../components/ui/slider'; import { Slider } from '../../components/ui/slider';
@@ -598,46 +586,6 @@
return rows; return rows;
}); });
const estimatedRowHeight = computed(() => {
const base = 148;
return Math.max(64, Math.round(base * cardScale.value * cardSpacing.value));
});
const virtualizerRef = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => {
const row = virtualRows.value[index];
if (row?.type === 'header') return 34;
if (row?.type === 'divider') return 18;
return estimatedRowHeight.value;
},
overscan: 10
}))
);
const virtualizer = computed(() => virtualizerRef.value);
const virtualItems = computed(() => virtualizer.value?.getVirtualItems?.() ?? []);
const virtualSpacerStyle = computed(() => {
const height = `${virtualizer.value?.getTotalSize?.() ?? 0}px`;
return `height:${height};position:relative;width:100%;`;
});
function virtualRowStyle(start) {
const y = Number(start) || 0;
return `transform:translateY(${y}px);position:absolute;top:0;left:0;width:100%;`;
}
function onVirtualRowRef(el) {
const target = el?.$el ?? el;
if (!target) {
return;
}
virtualizer.value?.measureElement?.(/** @type {Element} */ (target));
}
const virtualListStyle = computed(() => { const virtualListStyle = computed(() => {
const styleFn = gridStyle.value; const styleFn = gridStyle.value;
const total = filteredFriends.value.length; const total = filteredFriends.value.length;
@@ -650,11 +598,9 @@
}); });
watch([searchTerm, activeSegment], () => { watch([searchTerm, activeSegment], () => {
virtualizer.value?.scrollToOffset?.(0);
nextTick(() => { nextTick(() => {
resolveScrollViewport(); resolveScrollViewport();
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
}); });
@@ -666,11 +612,9 @@
activeSegment.value = 'online'; activeSegment.value = 'online';
} }
virtualizer.value?.scrollToOffset?.(0);
nextTick(() => { nextTick(() => {
resolveScrollViewport(); resolveScrollViewport();
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
}); });
@@ -680,7 +624,6 @@
nextTick(() => { nextTick(() => {
resolveScrollViewport(); resolveScrollViewport();
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
} }
); );
@@ -691,7 +634,6 @@
} }
nextTick(() => { nextTick(() => {
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
}); });
@@ -700,7 +642,6 @@
resolveScrollViewport(); resolveScrollViewport();
setupResizeHandling(); setupResizeHandling();
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
}); });
@@ -740,7 +681,6 @@
resolveScrollViewport(); resolveScrollViewport();
setupResizeHandling(); setupResizeHandling();
updateGridWidth(); updateGridWidth();
virtualizer.value?.measure?.();
}); });
} }
} }
@@ -791,6 +731,8 @@
width: 100%; width: 100%;
padding: 2px; padding: 2px;
box-sizing: border-box; box-sizing: border-box;
display: grid;
row-gap: calc(var(--friend-card-gap) - 4px);
} }
.friend-view__virtual-spacer { .friend-view__virtual-spacer {
@@ -803,10 +745,13 @@
} }
.friend-view__row { .friend-view__row {
display: flex; display: grid;
flex-wrap: nowrap; grid-template-columns: repeat(
var(--friend-grid-columns, 1),
minmax(var(--friend-card-min-width, 200px), var(--friend-card-target-width, 1fr))
);
gap: var(--friend-card-gap, 14px); gap: var(--friend-card-gap, 14px);
align-items: stretch; justify-content: start;
padding: 2px; padding: 2px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -98,8 +98,10 @@
transition: transition:
box-shadow 0.2s ease, box-shadow 0.2s ease,
transform 0.2s ease; transform 0.2s ease;
width: 100%;
max-width: var(--friend-card-target-width, 220px); max-width: var(--friend-card-target-width, 220px);
min-width: var(--friend-card-min-width, 220px); min-width: var(--friend-card-min-width, 220px);
box-sizing: border-box;
&:hover { &:hover {
transform: translateY(calc(-2px * var(--card-scale))); transform: translateY(calc(-2px * var(--card-scale)));
@@ -118,9 +120,6 @@
flex: none; flex: none;
} }
.friend-card__avatar {
}
.friend-card__status-dot { .friend-card__status-dot {
position: absolute; position: absolute;
top: calc(8px * var(--card-scale)); top: calc(8px * var(--card-scale));
-2
View File
@@ -175,8 +175,6 @@
}); });
const { const {
panelGroupRef,
asidePanelRef,
asideDefaultSize, asideDefaultSize,
asideMaxSize, asideMaxSize,
mainDefaultSize, mainDefaultSize,
+2 -2
View File
@@ -32,7 +32,7 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
}, },
{ {
accessorKey: 'created', accessorKey: 'created',
size: 100, size: 120,
header: ({ column }) => ( header: ({ column }) => (
<Button <Button
variant="ghost" variant="ghost"
@@ -65,7 +65,7 @@ export const createColumns = ({ onDelete, onDeletePrompt }) => {
}, },
{ {
accessorKey: 'type', accessorKey: 'type',
size: 100, size: 140,
header: () => t('table.moderation.type'), header: () => t('table.moderation.type'),
cell: ({ row }) => { cell: ({ row }) => {
const type = row.getValue('type'); const type = row.getValue('type');
@@ -32,6 +32,25 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="options-container-item">
<span class="name">{{ t('view.settings.appearance.appearance.font_family') }}</span>
<Select :model-value="appFontFamily" @update:modelValue="setAppFontFamily">
<SelectTrigger size="sm">
<SelectValue
:placeholder="t(`view.settings.appearance.appearance.font_family_${appFontFamily}`)" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="fontKey in appFontFamilyOptions"
:key="fontKey"
:value="fontKey">
{{ t(`view.settings.appearance.appearance.font_family_${fontKey}`) }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div v-if="!isLinux" class="options-container-item"> <div v-if="!isLinux" class="options-container-item">
<span class="name">{{ t('view.settings.appearance.appearance.zoom') }}</span> <span class="name">{{ t('view.settings.appearance.appearance.zoom') }}</span>
<NumberField <NumberField
@@ -431,7 +450,7 @@
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores'; import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
import { getLanguageName, languageCodes } from '../../../../localization'; import { getLanguageName, languageCodes } from '../../../../localization';
import { THEME_CONFIG } from '../../../../shared/constants'; import { APP_FONT_FAMILIES, THEME_CONFIG } from '../../../../shared/constants';
import SimpleSwitch from '../SimpleSwitch.vue'; import SimpleSwitch from '../SimpleSwitch.vue';
@@ -444,6 +463,7 @@
appLanguage, appLanguage,
themeMode, themeMode,
displayVRCPlusIconsAsAvatar, displayVRCPlusIconsAsAvatar,
appFontFamily,
hideNicknames, hideNicknames,
showInstanceIdInLocation, showInstanceIdInLocation,
isAgeGatedInstancesVisible, isAgeGatedInstancesVisible,
@@ -492,9 +512,12 @@
changeAppLanguage, changeAppLanguage,
promptMaxTableSizeDialog, promptMaxTableSizeDialog,
setNotificationIconDot, setNotificationIconDot,
setTablePageSizes setTablePageSizes,
setAppFontFamily
} = appearanceSettingsStore; } = appearanceSettingsStore;
const appFontFamilyOptions = APP_FONT_FAMILIES;
const zoomLevel = ref(100); const zoomLevel = ref(100);
const isLinux = computed(() => LINUX); const isLinux = computed(() => LINUX);
let cleanupWheel = null; let cleanupWheel = null;
+2 -3
View File
@@ -19,7 +19,7 @@
}}</span> }}</span>
<template v-else> <template v-else>
<div v-if="friend.pendingOffline" class="text-xs"> <div v-if="friend.pendingOffline" class="text-xs">
<AlertTriangle class="inline-block" /> {{ t('side_panel.pending_offline') }} {{ t('side_panel.pending_offline') }}
</div> </div>
<template v-else-if="isGroupByInstance"> <template v-else-if="isGroupByInstance">
<div class="flex items-center"> <div class="flex items-center">
@@ -65,9 +65,8 @@
</template> </template>
<script setup> <script setup>
import { AlertTriangle, Loader2, Trash2 } from 'lucide-vue-next'; import { Loader2, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { computed } from 'vue'; import { computed } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';