mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +02:00
theme and virtualized list
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
|
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
|
||||||
<DialogContent class="custom-nav-dialog">
|
<DialogContent class="custom-nav-dialog sm:min-w-[50vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
|
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a
|
<a
|
||||||
class="cursor-pointer"
|
class="cursor-pointer text-blue-600"
|
||||||
@click.prevent="openExternalLink('https://remixicon.com/')">
|
@click.prevent="openExternalLink('https://remixicon.com/')">
|
||||||
https://remixicon.com/
|
https://remixicon.com/
|
||||||
</a>
|
</a>
|
||||||
@@ -542,18 +542,18 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 430px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-nav-entry {
|
.custom-nav-entry {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 420px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="socialStatusDialog.visible">
|
<Dialog v-model:open="socialStatusDialog.visible">
|
||||||
<DialogContent class="x-dialog sm:max-w-100">
|
<DialogContent class="x-dialog sm:-w-20">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ t('dialog.social_status.header') }}</DialogTitle>
|
<DialogTitle>{{ t('dialog.social_status.header') }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div class="pt-6 pb-4 px-16">
|
||||||
<Select :model-value="socialStatusDialog.status" @update:modelValue="handleSocialStatusChange">
|
<div class="flex items-center gap-2">
|
||||||
<SelectTrigger size="sm" style="margin-top: 10px; width: 100%">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<i v-if="socialStatusDialog.status === 'join me'" class="x-user-status joinme"></i>
|
|
||||||
<i v-else-if="socialStatusDialog.status === 'active'" class="x-user-status online"></i>
|
|
||||||
<i v-else-if="socialStatusDialog.status === 'ask me'" class="x-user-status askme"></i>
|
|
||||||
<i v-else-if="socialStatusDialog.status === 'busy'" class="x-user-status busy"></i>
|
|
||||||
<i v-else-if="socialStatusDialog.status === 'offline'" class="x-user-status offline"></i>
|
|
||||||
<SelectValue :placeholder="t('dialog.social_status.status_placeholder')" />
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="join me" :text-value="t('dialog.user.status.join_me')">
|
|
||||||
<i class="x-user-status joinme"></i> {{ t('dialog.user.status.join_me') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="active" :text-value="t('dialog.user.status.online')">
|
|
||||||
<i class="x-user-status online"></i> {{ t('dialog.user.status.online') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ask me" :text-value="t('dialog.user.status.ask_me')">
|
|
||||||
<i class="x-user-status askme"></i> {{ t('dialog.user.status.ask_me') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="busy" :text-value="t('dialog.user.status.busy')">
|
|
||||||
<i class="x-user-status busy"></i> {{ t('dialog.user.status.busy') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
v-if="currentUser.$isModerator"
|
|
||||||
value="offline"
|
|
||||||
:text-value="t('dialog.user.status.offline')">
|
|
||||||
<i class="x-user-status offline"></i> {{ t('dialog.user.status.offline') }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<InputGroupField
|
<InputGroupField
|
||||||
v-model="socialStatusDialog.statusDescription"
|
v-model="socialStatusDialog.statusDescription"
|
||||||
:placeholder="t('dialog.social_status.status_placeholder')"
|
:placeholder="t('dialog.social_status.status_placeholder')"
|
||||||
:maxlength="32"
|
:maxlength="32"
|
||||||
clearable
|
clearable>
|
||||||
show-count
|
</InputGroupField>
|
||||||
class="mt-2.5" />
|
<DropdownMenu>
|
||||||
<Collapsible v-model:open="isOpen" class="mt-3 flex w-full flex-col gap-2">
|
<DropdownMenuTrigger as-child>
|
||||||
<div class="flex items-center justify-between gap-4 px-4">
|
<InputGroupButton variant="outline" size="icon-lg">
|
||||||
<h4 class="text-sm font-semibold">{{ t('dialog.social_status.history') }}</h4>
|
<History class="text-lg" />
|
||||||
<CollapsibleTrigger as-child>
|
</InputGroupButton>
|
||||||
<Button variant="ghost" size="icon" class="size-8">
|
</DropdownMenuTrigger>
|
||||||
<ChevronsUpDown />
|
<DropdownMenuContent align="start">
|
||||||
<span class="sr-only">Toggle</span>
|
<DropdownMenuItem v-if="!historyItems.length" disabled>
|
||||||
</Button>
|
{{ t('dialog.social_status.history') }}
|
||||||
</CollapsibleTrigger>
|
</DropdownMenuItem>
|
||||||
</div>
|
<DropdownMenuItem
|
||||||
<div
|
|
||||||
v-if="!isOpen && latestHistoryItem"
|
|
||||||
class="cursor-pointer rounded-md border w-full px-4 py-2 font-mono text-sm"
|
|
||||||
@click="setSocialStatusFromHistory(latestHistoryItem)">
|
|
||||||
{{ latestHistoryItem.status }}
|
|
||||||
</div>
|
|
||||||
<CollapsibleContent class="flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
v-for="item in historyItems"
|
v-for="item in historyItems"
|
||||||
:key="item.no ?? item.status"
|
:key="item.no ?? item.status"
|
||||||
class="cursor-pointer rounded-md border w-full px-4 py-2 font-mono text-sm"
|
|
||||||
@click="setSocialStatusFromHistory(item)">
|
@click="setSocialStatusFromHistory(item)">
|
||||||
{{ item.status }}
|
{{ item.status }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-2" role="radiogroup">
|
||||||
|
<Item
|
||||||
|
v-for="option in statusOptions"
|
||||||
|
:key="option.value"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
role="radio"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-checked="socialStatusDialog.status === option.value"
|
||||||
|
class="cursor-pointer hover:bg-accent"
|
||||||
|
@click="handleSocialStatusChange(option.value)"
|
||||||
|
@keydown.enter.prevent="handleSocialStatusChange(option.value)"
|
||||||
|
@keydown.space.prevent="handleSocialStatusChange(option.value)">
|
||||||
|
<ItemMedia>
|
||||||
|
<i class="x-user-status" :class="option.statusClass"></i>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{{ option.label }}</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Check v-if="socialStatusDialog.status === option.value" class="text-base text-primary" />
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent></Collapsible
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -86,13 +69,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item';
|
||||||
import { computed, ref } from 'vue';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Check, History } from 'lucide-vue-next';
|
||||||
|
import { InputGroupButton, InputGroupField } from '@/components/ui/input-group';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ChevronsUpDown } from 'lucide-vue-next';
|
import { computed } from 'vue';
|
||||||
import { InputGroupField } from '@/components/ui/input-group';
|
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -114,9 +102,40 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const historyItems = computed(() => props.socialStatusHistoryTable?.data ?? []);
|
const historyItems = computed(() => props.socialStatusHistoryTable?.data ?? []);
|
||||||
const latestHistoryItem = computed(() => historyItems.value[0] ?? null);
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
value: 'join me',
|
||||||
|
statusClass: 'joinme',
|
||||||
|
label: t('dialog.user.status.join_me')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'active',
|
||||||
|
statusClass: 'online',
|
||||||
|
label: t('dialog.user.status.online')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ask me',
|
||||||
|
statusClass: 'askme',
|
||||||
|
label: t('dialog.user.status.ask_me')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'busy',
|
||||||
|
statusClass: 'busy',
|
||||||
|
label: t('dialog.user.status.busy')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (currentUser.value?.$isModerator) {
|
||||||
|
options.push({
|
||||||
|
value: 'offline',
|
||||||
|
statusClass: 'offline',
|
||||||
|
label: t('dialog.user.status.offline')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
function handleSocialStatusChange(value) {
|
function handleSocialStatusChange(value) {
|
||||||
props.socialStatusDialog.status = String(value);
|
props.socialStatusDialog.status = String(value);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Primitive } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { itemVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false, default: "div" },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
variant: { type: null, required: false },
|
||||||
|
size: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="item"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(itemVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
:class="cn('flex items-center gap-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
:class="
|
||||||
|
cn('flex basis-full items-center justify-between gap-2', props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
:class="cn('group/item-group flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
:class="
|
||||||
|
cn('flex basis-full items-center justify-between gap-2', props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { itemMediaVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
variant: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
:data-variant="props.variant"
|
||||||
|
:class="cn(itemMediaVariants({ variant }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
orientation: { type: String, required: false },
|
||||||
|
decorative: { type: Boolean, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
:class="cn('my-0', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export { default as Item } from "./Item.vue";
|
||||||
|
export { default as ItemActions } from "./ItemActions.vue";
|
||||||
|
export { default as ItemContent } from "./ItemContent.vue";
|
||||||
|
export { default as ItemDescription } from "./ItemDescription.vue";
|
||||||
|
export { default as ItemFooter } from "./ItemFooter.vue";
|
||||||
|
export { default as ItemGroup } from "./ItemGroup.vue";
|
||||||
|
export { default as ItemHeader } from "./ItemHeader.vue";
|
||||||
|
export { default as ItemMedia } from "./ItemMedia.vue";
|
||||||
|
export { default as ItemSeparator } from "./ItemSeparator.vue";
|
||||||
|
export { default as ItemTitle } from "./ItemTitle.vue";
|
||||||
|
|
||||||
|
export const itemVariants = cva(
|
||||||
|
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "p-4 gap-4 ",
|
||||||
|
sm: "py-3 px-4 gap-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -942,7 +942,7 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"join_me": "Join Me",
|
"join_me": "Join Me",
|
||||||
"ask_me": "Ask Me",
|
"ask_me": "Ask Me",
|
||||||
"busy": "Busy"
|
"busy": "Do Not Disturb"
|
||||||
},
|
},
|
||||||
"previous_display_names": "Previous Display Names:",
|
"previous_display_names": "Previous Display Names:",
|
||||||
"pronouns": "Pronouns",
|
"pronouns": "Pronouns",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.841 0.238 128.85);
|
--sidebar-ring: oklch(0.841 0.238 128.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.dark {
|
:root[data-theme='midnight'] {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
@@ -33,3 +33,66 @@
|
|||||||
--sidebar-border: oklch(0.269 0 0);
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* blue */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='blue'] {
|
||||||
|
--primary: oklch(0.488 0.243 264.376);
|
||||||
|
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* green */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='green'] {
|
||||||
|
--primary: oklch(0.648 0.2 131.684);
|
||||||
|
--primary-foreground: oklch(0.986 0.031 120.757);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.405 0.101 131.063);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* orange */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='orange'] {
|
||||||
|
--primary: oklch(0.705 0.213 47.604);
|
||||||
|
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.408 0.123 38.172);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* red */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='red'] {
|
||||||
|
--primary: oklch(0.637 0.237 25.331);
|
||||||
|
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.396 0.141 25.723);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rose */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='rose'] {
|
||||||
|
--primary: oklch(0.645 0.246 16.439);
|
||||||
|
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.41 0.159 10.272);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* violet */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='violet'] {
|
||||||
|
--primary: oklch(0.606 0.25 292.717);
|
||||||
|
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.38 0.189 293.745);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* yellow */
|
||||||
|
:root[data-theme='midnight'][data-theme-color='yellow'] {
|
||||||
|
--primary: oklch(0.795 0.184 86.047);
|
||||||
|
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.421 0.095 57.708);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.75 0.183 55.934);
|
--sidebar-ring: oklch(0.75 0.183 55.934);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.704 0.191 22.216);
|
--sidebar-ring: oklch(0.704 0.191 22.216);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.712 0.194 13.428);
|
--sidebar-ring: oklch(0.712 0.194 13.428);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.702 0.183 293.541);
|
--sidebar-ring: oklch(0.702 0.183 293.541);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root[data-theme='light'] {
|
||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
--sidebar-ring: oklch(0.852 0.199 91.936);
|
--sidebar-ring: oklch(0.852 0.199 91.936);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
:root[data-theme='dark'] {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
|||||||
@@ -957,6 +957,10 @@
|
|||||||
clearSelectedAvatars();
|
clearSelectedAvatars();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelectedAvatars() {
|
||||||
|
selectedFavoriteAvatars.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
function isGroupActive(type, key) {
|
function isGroupActive(type, key) {
|
||||||
return selectedGroup.value?.type === type && selectedGroup.value?.key === key;
|
return selectedGroup.value?.type === type && selectedGroup.value?.key === key;
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1420,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']) {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-groups-panel {
|
.favorites-groups-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
@@ -1452,6 +1466,7 @@
|
|||||||
|
|
||||||
.group-item {
|
.group-item {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
|
|||||||
@@ -891,6 +891,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']) {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-groups-panel {
|
.favorites-groups-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
@@ -927,6 +937,7 @@
|
|||||||
|
|
||||||
.group-item {
|
.group-item {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
|
|||||||
@@ -379,25 +379,39 @@
|
|||||||
</template>
|
</template>
|
||||||
<div v-else class="favorites-empty">No Data</div>
|
<div v-else class="favorites-empty">No Data</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea
|
|
||||||
v-else-if="activeLocalGroupName && isLocalGroupSelected"
|
|
||||||
class="favorites-content__scroll">
|
|
||||||
<template v-if="currentLocalFavorites.length">
|
|
||||||
<div
|
<div
|
||||||
class="favorites-card-list"
|
v-else-if="activeLocalGroupName && isLocalGroupSelected"
|
||||||
:style="worldFavoritesGridStyle(currentLocalFavorites.length)">
|
ref="localFavoritesViewportRef"
|
||||||
|
class="favorites-content__scroll favorites-content__scroll--native favorites-content__scroll--local focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
data-reka-scroll-area-viewport=""
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
tabindex="0"
|
||||||
|
style="overflow: hidden scroll">
|
||||||
|
<template v-if="currentLocalFavorites.length">
|
||||||
|
<div class="favorites-card-virtual" :style="localVirtualContainerStyle">
|
||||||
|
<template v-for="item in localVirtualItems" :key="String(item.virtualItem.key)">
|
||||||
|
<div
|
||||||
|
v-if="item.row"
|
||||||
|
class="favorites-card-virtual-row"
|
||||||
|
:data-index="item.virtualItem.index"
|
||||||
|
:ref="localVirtualizer.measureElement"
|
||||||
|
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
|
||||||
|
<div class="favorites-card-virtual-row-grid">
|
||||||
<FavoritesWorldLocalItem
|
<FavoritesWorldLocalItem
|
||||||
v-for="favorite in currentLocalFavorites"
|
v-for="favorite in getLocalRowItems(item.row)"
|
||||||
:key="favorite.id"
|
:key="favorite.key"
|
||||||
:group="activeLocalGroupName"
|
:group="activeLocalGroupName"
|
||||||
:favorite="favorite"
|
:favorite="favorite.favorite"
|
||||||
:edit-mode="worldEditMode"
|
:edit-mode="worldEditMode"
|
||||||
@remove-local-world-favorite="removeLocalWorldFavorite"
|
@remove-local-world-favorite="removeLocalWorldFavorite"
|
||||||
@click="showWorldDialog(favorite.id)" />
|
@click="showWorldDialog(favorite.favorite.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="favorites-empty">No Data</div>
|
<div v-else class="favorites-empty">No Data</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
<div v-else class="favorites-empty">No Data</div>
|
<div v-else class="favorites-empty">No Data</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,11 +427,11 @@
|
|||||||
import { ArrowUpDown, Ellipsis, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
|
import { ArrowUpDown, Ellipsis, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
|
||||||
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
|
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -757,6 +771,86 @@
|
|||||||
return localWorldFavorites.value[activeLocalGroupName.value] || [];
|
return localWorldFavorites.value[activeLocalGroupName.value] || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const localFavoritesViewportRef = ref(null);
|
||||||
|
|
||||||
|
const getFavoritesGridMetrics = (count = 1, options = {}) => {
|
||||||
|
const styleFn = worldFavoritesGridStyle.value;
|
||||||
|
const styles = typeof styleFn === 'function' ? styleFn(count, options) : {};
|
||||||
|
const columnsRaw = styles['--favorites-grid-columns'] ?? 1;
|
||||||
|
const gapRaw = styles['--favorites-card-gap'] ?? 12;
|
||||||
|
const columns = Math.max(1, Number(columnsRaw) || 1);
|
||||||
|
const gap = Number(String(gapRaw).replace('px', '')) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
gap,
|
||||||
|
styles
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkLocalFavorites = (favorites = []) => {
|
||||||
|
const items = Array.isArray(favorites) ? favorites : [];
|
||||||
|
if (!items.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { columns } = getFavoritesGridMetrics(items.length, { matchMaxColumnWidth: true });
|
||||||
|
const safeColumns = Math.max(1, columns || 1);
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += safeColumns) {
|
||||||
|
rows.push({
|
||||||
|
type: 'cards',
|
||||||
|
key: `local:${activeLocalGroupName.value}:${index}`,
|
||||||
|
items: items.slice(index, index + safeColumns).map((favorite) => ({
|
||||||
|
key: favorite.id ?? favorite.worldId ?? favorite.name ?? `${index}:${Math.random()}`,
|
||||||
|
favorite
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localVirtualRows = computed(() => chunkLocalFavorites(currentLocalFavorites.value));
|
||||||
|
|
||||||
|
const estimateLocalRowSize = (row) => {
|
||||||
|
if (!row) {
|
||||||
|
return 120;
|
||||||
|
}
|
||||||
|
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
|
||||||
|
const { columns, gap } = getFavoritesGridMetrics(itemCount, { matchMaxColumnWidth: true });
|
||||||
|
const safeColumns = Math.max(1, columns || 1);
|
||||||
|
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
|
||||||
|
const baseCardHeight = 220;
|
||||||
|
const rowGap = Math.max(0, gap);
|
||||||
|
|
||||||
|
return rows * baseCardHeight + (rows - 1) * rowGap + 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localVirtualizer = useVirtualizer(
|
||||||
|
computed(() => ({
|
||||||
|
count: localVirtualRows.value.length,
|
||||||
|
getScrollElement: () => localFavoritesViewportRef.value,
|
||||||
|
estimateSize: (index) => estimateLocalRowSize(localVirtualRows.value[index]),
|
||||||
|
overscan: 8
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const localVirtualItems = computed(() => {
|
||||||
|
const items = localVirtualizer.value?.getVirtualItems?.() ?? [];
|
||||||
|
return items.map((virtualItem) => ({
|
||||||
|
virtualItem,
|
||||||
|
row: localVirtualRows.value[virtualItem.index]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const localVirtualContainerStyle = computed(() => ({
|
||||||
|
...getFavoritesGridMetrics(currentLocalFavorites.value.length, { matchMaxColumnWidth: true }).styles,
|
||||||
|
height: `${localVirtualizer.value?.getTotalSize?.() ?? 0}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getLocalRowItems = (row) => (row && Array.isArray(row.items) ? row.items : []);
|
||||||
|
|
||||||
function handleSortFavoritesChange(value) {
|
function handleSortFavoritesChange(value) {
|
||||||
const next = Boolean(value);
|
const next = Boolean(value);
|
||||||
if (next !== sortFavorites.value) {
|
if (next !== sortFavorites.value) {
|
||||||
@@ -797,6 +891,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([currentLocalFavorites, worldCardScale, worldCardSpacing, activeLocalGroupName], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
localVirtualizer.value?.measure?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => worldEditMode.value,
|
() => worldEditMode.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -1248,6 +1348,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']) {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:hover),
|
||||||
|
.favorites-splitter :deep([data-slot='resizable-handle']:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-dropdown {
|
.favorites-dropdown {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -1284,6 +1394,7 @@
|
|||||||
|
|
||||||
.group-item {
|
.group-item {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 0 6px rgba(15, 23, 42, 0.04);
|
||||||
@@ -1423,6 +1534,26 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-content__scroll--local {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-content__scroll--local::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-content__scroll--local::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-content__scroll--local::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-search-grid {
|
.favorites-search-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(
|
||||||
@@ -1445,6 +1576,33 @@
|
|||||||
padding: 4px 2px 12px 2px;
|
padding: 4px 2px 12px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-card-virtual {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-card-virtual-row {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-bottom: var(--favorites-card-gap, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-card-virtual-row-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(
|
||||||
|
var(--favorites-grid-columns, 1),
|
||||||
|
minmax(var(--favorites-card-min-width, 260px), var(--favorites-card-target-width, 1fr))
|
||||||
|
);
|
||||||
|
gap: var(--favorites-card-gap, 12px);
|
||||||
|
justify-content: start;
|
||||||
|
padding: 4px 2px 0 2px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-card-list::after {
|
.favorites-card-list::after {
|
||||||
content: '';
|
content: '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,35 +74,46 @@
|
|||||||
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
|
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
|
||||||
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
|
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll">
|
<div 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="virtualContainerStyle">
|
||||||
<div v-for="row in virtualRows" :key="String(row.key)" class="friend-view__virtual-row">
|
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
|
||||||
<template v-if="row.type === 'header'">
|
<div
|
||||||
|
v-if="item.row"
|
||||||
|
class="friend-view__virtual-row"
|
||||||
|
:class="`friend-view__virtual-row--${item.row.type}`"
|
||||||
|
:data-index="item.virtualItem.index"
|
||||||
|
:ref="virtualizer.measureElement"
|
||||||
|
:style="{ transform: `translateY(${item.virtualItem.start}px)` }">
|
||||||
|
<template v-if="item.row.type === 'header'">
|
||||||
<header class="friend-view__instance-header">
|
<header class="friend-view__instance-header">
|
||||||
<Location class="text-xs" :location="row.instanceId" style="display: inline" />
|
<Location
|
||||||
<span class="friend-view__instance-count">({{ row.count }})</span>
|
class="text-xs"
|
||||||
|
:location="getRowInstanceId(item.row)"
|
||||||
|
style="display: inline" />
|
||||||
|
<span class="friend-view__instance-count">({{ getRowCount(item.row) }})</span>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="row.type === 'divider'">
|
<template v-else-if="item.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 row.items ?? []"
|
v-for="card in getRowItems(item.row)"
|
||||||
:key="item.key"
|
:key="card.key"
|
||||||
:friend="item.friend"
|
:friend="card.friend"
|
||||||
:card-scale="cardScale"
|
:card-scale="cardScale"
|
||||||
:card-spacing="cardSpacing"
|
:card-spacing="cardSpacing"
|
||||||
:display-instance-info="item.displayInstanceInfo" />
|
:display-instance-info="card.displayInstanceInfo" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</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>
|
||||||
</ScrollArea>
|
</div>
|
||||||
<div v-else class="friend-view__initial-loading">
|
<div v-else class="friend-view__initial-loading">
|
||||||
<Loader2 class="friend-view__loading-icon" :size="22" />
|
<Loader2 class="friend-view__loading-icon" :size="22" />
|
||||||
</div>
|
</div>
|
||||||
@@ -110,15 +121,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldContent, FieldLabel } from '@/components/ui/field';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Loader2, Settings } from 'lucide-vue-next';
|
import { Loader2, Settings } from 'lucide-vue-next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { InputGroupSearch } from '@/components/ui/input-group';
|
import { InputGroupSearch } from '@/components/ui/input-group';
|
||||||
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';
|
||||||
@@ -205,13 +216,12 @@
|
|||||||
const searchTerm = ref('');
|
const searchTerm = ref('');
|
||||||
|
|
||||||
const scrollbarRef = ref();
|
const scrollbarRef = ref();
|
||||||
const scrollViewportRef = shallowRef(null);
|
|
||||||
const gridWidth = ref(0);
|
const gridWidth = ref(0);
|
||||||
let resizeObserver;
|
let resizeObserver;
|
||||||
let cleanupResize;
|
let cleanupResize;
|
||||||
|
|
||||||
const updateGridWidth = () => {
|
const updateGridWidth = () => {
|
||||||
const wrap = scrollViewportRef.value;
|
const wrap = scrollbarRef.value;
|
||||||
if (!wrap) {
|
if (!wrap) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -225,7 +235,7 @@
|
|||||||
cleanupResize = undefined;
|
cleanupResize = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrap = scrollViewportRef.value;
|
const wrap = scrollbarRef.value;
|
||||||
if (!wrap) {
|
if (!wrap) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -475,15 +485,70 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveScrollViewport() {
|
const getGridMetrics = (count = 1, options = {}) => {
|
||||||
const rootEl = scrollbarRef.value?.$el ?? null;
|
const baseWidth = 220;
|
||||||
if (!rootEl) {
|
const baseGap = 14;
|
||||||
scrollViewportRef.value = null;
|
const scale = cardScale.value;
|
||||||
return;
|
const spacing = cardSpacing.value;
|
||||||
|
const minWidth = baseWidth * scale;
|
||||||
|
const gap = Math.max(6, (baseGap + (scale - 1) * 10) * spacing);
|
||||||
|
|
||||||
|
const containerWidth = Math.max(gridWidth.value ?? 0, 0);
|
||||||
|
const itemCount = Math.max(Number(count) || 0, 0);
|
||||||
|
const safeCount = itemCount > 0 ? itemCount : 1;
|
||||||
|
const maxColumns = Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)) || 1);
|
||||||
|
const preferredColumns = options?.preferredColumns;
|
||||||
|
const requestedColumns = preferredColumns
|
||||||
|
? Math.max(1, Math.min(Math.round(preferredColumns), maxColumns))
|
||||||
|
: maxColumns;
|
||||||
|
const columns = Math.max(1, Math.min(safeCount, requestedColumns));
|
||||||
|
const forceStretch = Boolean(options?.forceStretch);
|
||||||
|
const disableAutoStretch = Boolean(options?.disableAutoStretch);
|
||||||
|
const matchMaxColumnWidth = Boolean(options?.matchMaxColumnWidth);
|
||||||
|
const shouldStretch = !disableAutoStretch && (forceStretch || itemCount >= maxColumns);
|
||||||
|
|
||||||
|
let cardWidth = minWidth;
|
||||||
|
const maxColumnWidth = maxColumns > 0 ? (containerWidth - gap * (maxColumns - 1)) / maxColumns : minWidth;
|
||||||
|
|
||||||
|
if (shouldStretch && columns > 0) {
|
||||||
|
const columnsWidth = containerWidth - gap * (columns - 1);
|
||||||
|
const rawWidth = columnsWidth > 0 ? columnsWidth / columns : minWidth;
|
||||||
|
|
||||||
|
if (Number.isFinite(rawWidth) && rawWidth > 0) {
|
||||||
|
cardWidth = Math.max(minWidth, rawWidth);
|
||||||
}
|
}
|
||||||
scrollViewportRef.value = rootEl.querySelector('[data-slot="scroll-area-viewport"]');
|
} else if (matchMaxColumnWidth && Number.isFinite(maxColumnWidth) && maxColumnWidth > 0) {
|
||||||
|
cardWidth = Math.max(minWidth, maxColumnWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minWidth,
|
||||||
|
gap,
|
||||||
|
columns,
|
||||||
|
cardWidth
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkCardItems = (items = [], keyPrefix = 'row') => {
|
||||||
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
if (!safeItems.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { columns } = getGridMetrics(safeItems.length, { matchMaxColumnWidth: true });
|
||||||
|
const safeColumns = Math.max(1, columns || 1);
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < safeItems.length; index += safeColumns) {
|
||||||
|
rows.push({
|
||||||
|
type: 'cards',
|
||||||
|
key: `${keyPrefix}:${index}`,
|
||||||
|
items: safeItems.slice(index, index + safeColumns)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
const virtualRows = computed(() => {
|
const virtualRows = computed(() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
||||||
@@ -498,15 +563,12 @@
|
|||||||
|
|
||||||
const friends = Array.isArray(group.friends) ? group.friends : [];
|
const friends = Array.isArray(group.friends) ? group.friends : [];
|
||||||
if (friends.length) {
|
if (friends.length) {
|
||||||
rows.push({
|
const items = friends.map((friend) => ({
|
||||||
type: 'cards',
|
|
||||||
key: `g:${group.instanceId}`,
|
|
||||||
items: friends.map((friend) => ({
|
|
||||||
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
|
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
|
||||||
friend,
|
friend,
|
||||||
displayInstanceInfo: true
|
displayInstanceInfo: true
|
||||||
}))
|
}));
|
||||||
});
|
rows.push(...chunkCardItems(items, `g:${group.instanceId}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,15 +586,12 @@
|
|||||||
|
|
||||||
const friends = Array.isArray(group.friends) ? group.friends : [];
|
const friends = Array.isArray(group.friends) ? group.friends : [];
|
||||||
if (friends.length) {
|
if (friends.length) {
|
||||||
rows.push({
|
const items = friends.map((friend) => ({
|
||||||
type: 'cards',
|
|
||||||
key: `mg:${group.instanceId}`,
|
|
||||||
items: friends.map((friend) => ({
|
|
||||||
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
|
key: `f:${friend?.id ?? friend?.userId ?? friend?.displayName ?? Math.random()}`,
|
||||||
friend,
|
friend,
|
||||||
displayInstanceInfo: false
|
displayInstanceInfo: false
|
||||||
}))
|
}));
|
||||||
});
|
rows.push(...chunkCardItems(items, `mg:${group.instanceId}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,15 +601,12 @@
|
|||||||
|
|
||||||
const online = mergedOnlineEntries.value;
|
const online = mergedOnlineEntries.value;
|
||||||
if (online.length) {
|
if (online.length) {
|
||||||
rows.push({
|
const items = online.map((entry) => ({
|
||||||
type: 'cards',
|
|
||||||
key: 'o:merged',
|
|
||||||
items: online.map((entry) => ({
|
|
||||||
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
||||||
friend: entry.friend,
|
friend: entry.friend,
|
||||||
displayInstanceInfo: true
|
displayInstanceInfo: true
|
||||||
}))
|
}));
|
||||||
});
|
rows.push(...chunkCardItems(items, 'o:merged'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
@@ -558,15 +614,12 @@
|
|||||||
|
|
||||||
const entries = filteredFriends.value;
|
const entries = filteredFriends.value;
|
||||||
if (entries.length) {
|
if (entries.length) {
|
||||||
rows.push({
|
const items = entries.map((entry) => ({
|
||||||
type: 'cards',
|
|
||||||
key: 'r:all',
|
|
||||||
items: entries.map((entry) => ({
|
|
||||||
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
key: `e:${entry?.id ?? entry?.friend?.id ?? entry?.friend?.displayName ?? Math.random()}`,
|
||||||
friend: entry.friend,
|
friend: entry.friend,
|
||||||
displayInstanceInfo: true
|
displayInstanceInfo: true
|
||||||
}))
|
}));
|
||||||
});
|
rows.push(...chunkCardItems(items, 'r:all'));
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
@@ -582,10 +635,60 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const estimateRowSize = (row) => {
|
||||||
|
if (!row) {
|
||||||
|
return 48;
|
||||||
|
}
|
||||||
|
if (row.type === 'header') {
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
if (row.type === 'divider') {
|
||||||
|
return 36;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCount = Array.isArray(row.items) ? row.items.length : 0;
|
||||||
|
const { columns, gap } = getGridMetrics(itemCount, { matchMaxColumnWidth: true });
|
||||||
|
const safeColumns = Math.max(1, columns || 1);
|
||||||
|
const rows = Math.max(1, Math.ceil(itemCount / safeColumns));
|
||||||
|
const scale = cardScale.value;
|
||||||
|
const spacing = cardSpacing.value;
|
||||||
|
const baseCardHeight = 150;
|
||||||
|
const cardHeight = baseCardHeight * scale * spacing;
|
||||||
|
const rowGap = Math.max(0, gap - 4);
|
||||||
|
|
||||||
|
return rows * cardHeight + (rows - 1) * rowGap + 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer(
|
||||||
|
computed(() => ({
|
||||||
|
count: virtualRows.value.length,
|
||||||
|
getScrollElement: () => scrollbarRef.value,
|
||||||
|
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
|
||||||
|
overscan: 5
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtualItems = computed(() => {
|
||||||
|
const items = virtualizer.value?.getVirtualItems?.() ?? [];
|
||||||
|
return items.map((virtualItem) => ({
|
||||||
|
virtualItem,
|
||||||
|
row: virtualRows.value[virtualItem.index]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualContainerStyle = computed(() => ({
|
||||||
|
...virtualListStyle.value,
|
||||||
|
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getRowItems = (row) => (row && Array.isArray(row.items) ? row.items : []);
|
||||||
|
const getRowInstanceId = (row) => (row && row.type === 'header' ? row.instanceId : '');
|
||||||
|
const getRowCount = (row) => (row && row.type === 'header' ? row.count : 0);
|
||||||
|
|
||||||
watch([searchTerm, activeSegment], () => {
|
watch([searchTerm, activeSegment], () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolveScrollViewport();
|
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -598,8 +701,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolveScrollViewport();
|
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -607,8 +710,8 @@
|
|||||||
() => filteredFriends.value.length,
|
() => filteredFriends.value.length,
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolveScrollViewport();
|
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -619,14 +722,21 @@
|
|||||||
}
|
}
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(virtualRows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolveScrollViewport();
|
|
||||||
setupResizeHandling();
|
setupResizeHandling();
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -663,9 +773,9 @@
|
|||||||
} finally {
|
} finally {
|
||||||
settingsReady.value = true;
|
settingsReady.value = true;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resolveScrollViewport();
|
|
||||||
setupResizeHandling();
|
setupResizeHandling();
|
||||||
updateGridWidth();
|
updateGridWidth();
|
||||||
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,6 +790,13 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view.x-container {
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__toolbar {
|
.friend-view__toolbar {
|
||||||
@@ -721,17 +838,31 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: grid;
|
position: relative;
|
||||||
row-gap: calc(var(--friend-card-gap) - 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-view__virtual-spacer {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__virtual-row {
|
.friend-view__virtual-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__virtual-row--header {
|
||||||
|
padding: 4px 10px;
|
||||||
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__virtual-row--divider {
|
||||||
|
padding: 16px 4px;
|
||||||
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__virtual-row--cards {
|
||||||
|
padding: 2px;
|
||||||
|
padding-bottom: calc(var(--friend-card-gap, 14px) - 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__row {
|
.friend-view__row {
|
||||||
@@ -784,7 +915,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__scroll {
|
.friend-view__scroll {
|
||||||
padding: 2px;
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__initial-loading {
|
.friend-view__initial-loading {
|
||||||
@@ -793,33 +926,10 @@
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__grid {
|
|
||||||
display: grid;
|
|
||||||
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, 18px);
|
|
||||||
justify-content: start;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-view__instances {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-view__instance {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-view__instance-header {
|
.friend-view__instance-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 2px;
|
padding: 4px 2px;
|
||||||
margin: 5px 10px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -828,7 +938,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin: 16px 4px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -856,15 +965,6 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-view__loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 18px 0 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-view__loading-icon {
|
.friend-view__loading-icon {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card class="friend-card p-0 gap-0" :style="cardStyle" @click="showUserDialog(friend.id)">
|
<Card class="friend-card p-0 gap-0" :style="cardStyle" @click="showUserDialog(friend.id)">
|
||||||
<div class="friend-card__header">
|
<div class="friend-card__header">
|
||||||
<div class="friend-card__avatar-wrapper">
|
<div>
|
||||||
<Avatar class="friend-card__avatar" :style="{ width: `${avatarSize}px`, height: `${avatarSize}px` }">
|
<Avatar class="friend-card__avatar" :style="{ width: `${avatarSize}px`, height: `${avatarSize}px` }">
|
||||||
<AvatarImage :src="userImage(props.friend.ref, true)" />
|
<AvatarImage :src="userImage(props.friend.ref, true)" />
|
||||||
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
|
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
'--card-scale': props.cardScale,
|
'--card-scale': props.cardScale,
|
||||||
'--card-spacing': props.cardSpacing,
|
'--card-spacing': props.cardSpacing,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: `${16 * props.cardScale * props.cardSpacing}px`
|
padding: `${24 * props.cardScale * props.cardSpacing}px`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const avatarFallback = computed(() => props.friend?.name?.charAt(0) ?? '?');
|
const avatarFallback = computed(() => props.friend?.name?.charAt(0) ?? '?');
|
||||||
@@ -115,11 +115,6 @@
|
|||||||
gap: calc(12px * var(--card-scale) * var(--card-spacing));
|
gap: calc(12px * var(--card-scale) * var(--card-spacing));
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-card__avatar-wrapper {
|
|
||||||
position: static;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-card__status-dot {
|
.friend-card__status-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(8px * var(--card-scale));
|
top: calc(8px * var(--card-scale));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<InputGroupField
|
<InputGroupField
|
||||||
id="login-form-username"
|
id="login-form-username"
|
||||||
:model-value="field.value"
|
:model-value="field.value"
|
||||||
|
autocomplete="off"
|
||||||
name="username"
|
name="username"
|
||||||
:placeholder="t('view.login.field.username')"
|
:placeholder="t('view.login.field.username')"
|
||||||
:aria-invalid="!!errors.length"
|
:aria-invalid="!!errors.length"
|
||||||
@@ -47,11 +48,11 @@
|
|||||||
id="login-form-password"
|
id="login-form-password"
|
||||||
:model-value="field.value"
|
:model-value="field.value"
|
||||||
type="password"
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
name="password"
|
name="password"
|
||||||
:placeholder="t('view.login.field.password')"
|
:placeholder="t('view.login.field.password')"
|
||||||
:aria-invalid="!!errors.length"
|
:aria-invalid="!!errors.length"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
|
||||||
@update:modelValue="field.onChange"
|
@update:modelValue="field.onChange"
|
||||||
@blur="field.onBlur" />
|
@blur="field.onBlur" />
|
||||||
<FieldError v-if="errors.length" :errors="errors" />
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
<InputGroupField
|
<InputGroupField
|
||||||
id="login-form-endpoint"
|
id="login-form-endpoint"
|
||||||
:model-value="field.value"
|
:model-value="field.value"
|
||||||
|
autocomplete="off"
|
||||||
name="endpoint"
|
name="endpoint"
|
||||||
:placeholder="AppDebug.endpointDomainVrchat"
|
:placeholder="AppDebug.endpointDomainVrchat"
|
||||||
:aria-invalid="!!errors.length"
|
:aria-invalid="!!errors.length"
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
<InputGroupField
|
<InputGroupField
|
||||||
id="login-form-websocket"
|
id="login-form-websocket"
|
||||||
:model-value="field.value"
|
:model-value="field.value"
|
||||||
|
autocomplete="off"
|
||||||
name="websocket"
|
name="websocket"
|
||||||
:placeholder="AppDebug.websocketDomainVrchat"
|
:placeholder="AppDebug.websocketDomainVrchat"
|
||||||
:aria-invalid="!!errors.length"
|
:aria-invalid="!!errors.length"
|
||||||
@@ -131,7 +134,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in savedCredentials"
|
v-for="user in savedCredentials"
|
||||||
:key="user.user.id"
|
:key="user.user.id"
|
||||||
class="x-friend-item"
|
class="x-friend-item hover:bg-muted rounded-xs"
|
||||||
@click="clickSavedLogin(user)">
|
@click="clickSavedLogin(user)">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<img :src="userImage(user.user)" loading="lazy" />
|
<img :src="userImage(user.user)" loading="lazy" />
|
||||||
|
|||||||
@@ -17,21 +17,7 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-container-item">
|
|
||||||
<span class="name">{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
|
|
||||||
<Select :model-value="themeMode" @update:modelValue="setThemeMode">
|
|
||||||
<SelectTrigger size="sm">
|
|
||||||
<SelectValue :placeholder="themeDisplayName(themeMode)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem v-for="(config, themeKey) in THEME_CONFIG" :key="themeKey" :value="themeKey">
|
|
||||||
{{ themeDisplayName(themeKey) }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="options-container-item">
|
<div class="options-container-item">
|
||||||
<span class="name flex! items-center!">
|
<span class="name flex! items-center!">
|
||||||
{{ t('view.settings.appearance.appearance.font_family') }}
|
{{ t('view.settings.appearance.appearance.font_family') }}
|
||||||
@@ -466,28 +452,18 @@
|
|||||||
import PresetColorPicker from '@/components/PresetColorPicker.vue';
|
import PresetColorPicker from '@/components/PresetColorPicker.vue';
|
||||||
|
|
||||||
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
|
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
|
||||||
import { APP_FONT_FAMILIES, THEME_CONFIG } from '../../../../shared/constants';
|
|
||||||
import { getLanguageName, languageCodes } from '../../../../localization';
|
import { getLanguageName, languageCodes } from '../../../../localization';
|
||||||
|
import { APP_FONT_FAMILIES } from '../../../../shared/constants';
|
||||||
|
|
||||||
import SimpleSwitch from '../SimpleSwitch.vue';
|
import SimpleSwitch from '../SimpleSwitch.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
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 appearanceSettingsStore = useAppearanceSettingsStore();
|
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||||
const { saveOpenVROption, updateVRConfigVars } = useVrStore();
|
const { saveOpenVROption, updateVRConfigVars } = useVrStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
appLanguage,
|
appLanguage,
|
||||||
themeMode,
|
|
||||||
displayVRCPlusIconsAsAvatar,
|
displayVRCPlusIconsAsAvatar,
|
||||||
appFontFamily,
|
appFontFamily,
|
||||||
hideNicknames,
|
hideNicknames,
|
||||||
@@ -534,7 +510,6 @@
|
|||||||
setHideUserMemos,
|
setHideUserMemos,
|
||||||
setHideUnfriends,
|
setHideUnfriends,
|
||||||
updateTrustColor,
|
updateTrustColor,
|
||||||
setThemeMode,
|
|
||||||
changeAppLanguage,
|
changeAppLanguage,
|
||||||
promptMaxTableSizeDialog,
|
promptMaxTableSizeDialog,
|
||||||
setNotificationIconDot,
|
setNotificationIconDot,
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
:placeholder="t('dialog.primary_password.password_placeholder')"
|
:placeholder="t('dialog.primary_password.password_placeholder')"
|
||||||
type="password"
|
type="password"
|
||||||
size="sm"
|
size="sm"
|
||||||
maxlength="32"
|
:maxlength="32"
|
||||||
show-password
|
|
||||||
autofocus />
|
autofocus />
|
||||||
<InputGroupField
|
<InputGroupField
|
||||||
v-model="enablePrimaryPasswordDialog.rePassword"
|
v-model="enablePrimaryPasswordDialog.rePassword"
|
||||||
@@ -18,8 +17,7 @@
|
|||||||
type="password"
|
type="password"
|
||||||
style="margin-top: 5px"
|
style="margin-top: 5px"
|
||||||
size="sm"
|
size="sm"
|
||||||
maxlength="32"
|
:maxlength="32" />
|
||||||
show-password />
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
:disabled="
|
:disabled="
|
||||||
|
|||||||
Reference in New Issue
Block a user