replace element plus components

This commit is contained in:
pa
2026-01-15 22:38:09 +09:00
committed by Natsumi
parent bdc1d3a347
commit c430ce1b63
46 changed files with 2143 additions and 1752 deletions

View File

@@ -1,32 +1,28 @@
<template>
<TooltipProvider>
<el-config-provider
:locale="/** @type {import('element-plus/es/locale').Language} */ (messages[locale].elementPlus)">
<MacOSTitleBar></MacOSTitleBar>
<MacOSTitleBar></MacOSTitleBar>
<div
id="x-app"
class="x-app"
:class="{ 'with-macos-titlebar': isMacOS }"
ondragenter="event.preventDefault()"
ondragover="event.preventDefault()"
ondrop="event.preventDefault()">
<RouterView></RouterView>
<Toaster position="top-center"></Toaster>
<div
id="x-app"
class="x-app"
:class="{ 'with-macos-titlebar': isMacOS }"
ondragenter="event.preventDefault()"
ondragover="event.preventDefault()"
ondrop="event.preventDefault()">
<RouterView></RouterView>
<Toaster position="top-center"></Toaster>
<AlertDialogModal></AlertDialogModal>
<PromptDialogModal></PromptDialogModal>
<AlertDialogModal></AlertDialogModal>
<PromptDialogModal></PromptDialogModal>
<VRCXUpdateDialog></VRCXUpdateDialog>
</div>
<div id="x-dialog-portal" class="x-dialog-portal"></div>
</el-config-provider>
<VRCXUpdateDialog></VRCXUpdateDialog>
</div>
<div id="x-dialog-portal" class="x-dialog-portal"></div>
</TooltipProvider>
</template>
<script setup>
import { computed, onBeforeMount, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Toaster } from './components/ui/sonner';
import { TooltipProvider } from './components/ui/tooltip';
@@ -45,8 +41,6 @@
const isMacOS = computed(() => navigator.platform.includes('Mac'));
const { locale, messages } = useI18n();
initNoty();
const store = createGlobalStores();

View File

@@ -0,0 +1,177 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowUp } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
const props = defineProps({
target: { type: [String, Object], default: null },
bottom: { type: Number, default: 20 },
right: { type: Number, default: 20 },
visibilityHeight: { type: Number, default: 200 },
behavior: {
type: String,
default: 'smooth',
validator: (value) => value === 'auto' || value === 'smooth'
},
tooltip: { type: Boolean, default: true },
tooltipText: { type: String, default: 'Back to top' },
teleport: { type: Boolean, default: true }
});
const visible = ref(false);
let containerEl = null;
function resolveTarget() {
if (!props.target) return null;
if (typeof props.target === 'string') {
return document.querySelector(props.target);
}
if (typeof props.target === 'object') {
if ('value' in props.target) {
return props.target.value;
}
if ('$el' in props.target) {
return props.target.$el;
}
}
return props.target;
}
function getScrollTop() {
if (!containerEl || typeof containerEl.scrollTop !== 'number') {
return window.scrollY || document.documentElement.scrollTop || 0;
}
return containerEl.scrollTop || 0;
}
function handleScroll() {
visible.value = getScrollTop() >= props.visibilityHeight;
}
function scrollToTop() {
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
if (!containerEl || typeof containerEl.scrollTo !== 'function') {
window.scrollTo({ top: 0, behavior });
return;
}
containerEl.scrollTo({ top: 0, behavior });
}
function bind() {
containerEl = resolveTarget();
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
target.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
}
function unbind() {
const target = containerEl || window;
target.removeEventListener('scroll', handleScroll);
}
onMounted(() => {
bind();
});
watch(
() => props.target,
() => {
unbind();
bind();
}
);
onBeforeUnmount(() => {
unbind();
});
const wrapperStyle = computed(
() => `position:fixed; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
);
</script>
<template>
<Teleport v-if="teleport" to="body">
<Transition name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<TooltipProvider v-if="tooltip">
<Tooltip>
<TooltipTrigger as-child>
<Button
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{{ tooltipText }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp class="h-4 w-4" />
</Button>
</div>
</Transition>
</Teleport>
<Transition v-else name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<Button
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp class="h-4 w-4" />
</Button>
</div>
</Transition>
</template>
<style scoped>
.back-to-top-enter-active,
.back-to-top-leave-active {
transition:
opacity 160ms ease,
transform 160ms ease;
}
.back-to-top-enter-from,
.back-to-top-leave-to {
opacity: 0;
transform: translateY(6px);
}
.back-to-top-enter-to,
.back-to-top-leave-from {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.back-to-top-enter-active,
.back-to-top-leave-active {
transition: none;
}
}
</style>

View File

@@ -1,154 +1,332 @@
<template>
<div
v-if="fullscreenImageDialog.visible"
class="fullscreen-image-overlay"
:style="{ zIndex: overlayZIndex }"
@click.self="closeDialog">
<el-image
v-if="fullscreenImageDialog.imageUrl"
ref="imageRef"
class="fullscreen-image"
:src="fullscreenImageDialog.imageUrl"
:preview-src-list="[fullscreenImageDialog.imageUrl]"
:z-index="100000"
fit="contain"
preview-teleported
hide-on-click-modal
:initial-index="0"
@load="handleImageLoad"
@close="closeDialog">
<template #toolbar="{ actions }">
<Copy @click="copyImageToClipboard(fullscreenImageDialog.imageUrl)" class="toolbar-icon" />
<Download
@click="downloadAndSaveImage(fullscreenImageDialog.imageUrl, fullscreenImageDialog.fileName)"
class="toolbar-icon" />
<ZoomOut @click="actions('zoomOut')" class="toolbar-icon" />
<ZoomIn @click="actions('zoomIn')" class="toolbar-icon" />
<RotateCw @click="actions('clockwise')" class="toolbar-icon" />
<RotateCcw @click="actions('anticlockwise')" class="toolbar-icon" />
</template>
</el-image>
</div>
<Dialog v-model:open="open">
<DialogPortal :to="portalTo">
<RekaDialogOverlay class="fixed inset-0 bg-background/80 backdrop-blur-sm" @click="closeDialog" />
<RekaDialogContent
class="fixed inset-0 p-6 sm:p-10 border-0 bg-transparent shadow-none outline-none"
@open-auto-focus.prevent
@close-auto-focus.prevent>
<div ref="viewerEl" class="relative h-full w-full overflow-hidden select-none">
<!-- toolbar -->
<div
class="absolute right-3 top-3 z-10 flex items-center gap-2 rounded-md bg-background/70 backdrop-blur px-2 py-1 border">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="!imageUrl"
@click="copyImageToClipboard(imageUrl)"
aria-label="Copy">
<Copy class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="!imageUrl"
@click="downloadAndSaveImage(imageUrl, fullscreenImageDialog.fileName)"
aria-label="Download">
<Download class="h-4 w-4" />
</Button>
<div class="mx-1 h-5 w-px bg-border" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="zoomOutCenter"
aria-label="Zoom out">
<ZoomOut class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="zoomInCenter" aria-label="Zoom in">
<ZoomIn class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="rotateCW"
aria-label="Rotate clockwise">
<RotateCw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="rotateCCW"
aria-label="Rotate counterclockwise">
<RotateCcw class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="resetTransform" aria-label="Reset">
<RefreshCcw class="h-4 w-4" />
</Button>
<div class="mx-1 h-5 w-px bg-border" />
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeDialog" aria-label="Close">
<X class="h-4 w-4" />
</Button>
</div>
<div
class="h-full w-full flex items-center justify-center"
@wheel="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp">
<img
v-if="imageUrl"
:src="imageUrl"
class="max-h-full max-w-full x-viewer-img"
:style="transformStyle"
draggable="false" />
</div>
</div>
</RekaDialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup>
import { Copy, Download, RotateCcw, RotateCw, ZoomIn, ZoomOut } from 'lucide-vue-next';
import { nextTick, ref, watch } from 'vue';
import { Copy, Download, RefreshCcw, RotateCcw, RotateCw, X, ZoomIn, ZoomOut } from 'lucide-vue-next';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { DialogContent as RekaDialogContent, DialogOverlay as RekaDialogOverlay, DialogPortal } from 'reka-ui';
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { acquireModalPortalLayer } from '@/lib/modalPortalLayers';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import Noty from 'noty';
import { escapeTag, extractFileId } from '../shared/utils';
import { getNextDialogIndex } from '../shared/utils/base/ui';
import { useGalleryStore } from '../stores';
const galleryStore = useGalleryStore();
const { fullscreenImageDialog } = storeToRefs(galleryStore);
const imageRef = ref();
const overlayZIndex = ref(4000);
const viewerEl = ref(null);
const portalLayer = acquireModalPortalLayer();
const portalTo = portalLayer.element;
function showPreview() {
nextTick(() => {
imageRef.value?.showPreview?.();
});
const scale = ref(1);
const rotate = ref(0); // deg
const tx = ref(0);
const ty = ref(0);
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const startTx = ref(0);
const startTy = ref(0);
const imageUrl = computed(() => fullscreenImageDialog.value.imageUrl || '');
const open = computed({
get: () => fullscreenImageDialog.value.visible,
set: (v) => {
fullscreenImageDialog.value.visible = v;
}
});
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function degToRad(deg) {
return (deg * Math.PI) / 180;
}
function handleImageLoad() {
showPreview();
function resetTransform() {
scale.value = 1;
rotate.value = 0;
tx.value = 0;
ty.value = 0;
}
watch(
() => fullscreenImageDialog.value.visible,
(visible) => {
if (!visible) {
return;
}
overlayZIndex.value = Math.max(getNextDialogIndex(), 4000);
showPreview();
}
);
watch(
() => fullscreenImageDialog.value.imageUrl,
(url) => {
if (!url || !fullscreenImageDialog.value.visible) {
return;
}
showPreview();
}
);
function closeDialog() {
fullscreenImageDialog.value.visible = false;
open.value = false;
}
async function copyImageToClipboard(url) {
if (!url) {
function zoomAtCenter(factor) {
const el = viewerEl.value;
if (!el) {
scale.value = clamp(scale.value * factor, 0.1, 10);
return;
}
scale.value = clamp(scale.value * factor, 0.1, 10);
}
function zoomInCenter() {
zoomAtCenter(1.2);
}
function zoomOutCenter() {
zoomAtCenter(1 / 1.2);
}
function rotateCW() {
rotate.value = (rotate.value + 90) % 360;
}
function rotateCCW() {
rotate.value = (rotate.value - 90 + 360) % 360;
}
function zoomAtPointer(e, factor) {
const el = viewerEl.value;
if (!el) return;
const rect = el.getBoundingClientRect();
// mouse in container space
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// container center
const cx = rect.width / 2;
const cy = rect.height / 2;
const oldScale = scale.value;
const newScale = clamp(oldScale * factor, 0.1, 10);
const r = degToRad(rotate.value);
const cos = Math.cos(r);
const sin = Math.sin(r);
// vector from transformed center (includes current translation)
const vx = mx - cx - tx.value;
const vy = my - cy - ty.value;
// inverse rotate + unscale => local point
const ux = (vx * cos + vy * sin) / oldScale;
const uy = (-vx * sin + vy * cos) / oldScale;
// forward rotate + scale => new vector
const v2x = (ux * cos - uy * sin) * newScale;
const v2y = (ux * sin + uy * cos) * newScale;
// keep pointer anchored
tx.value = mx - cx - v2x;
ty.value = my - cy - v2y;
scale.value = newScale;
}
function onWheel(e) {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
zoomAtPointer(e, factor);
}
function onPointerDown(e) {
if (e.button !== 0) return;
isDragging.value = true;
e.currentTarget.setPointerCapture?.(e.pointerId);
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
startTx.value = tx.value;
startTy.value = ty.value;
}
function onPointerMove(e) {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
tx.value = startTx.value + dx;
ty.value = startTy.value + dy;
}
function onPointerUp(e) {
if (!isDragging.value) return;
isDragging.value = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
}
const transformStyle = computed(() => ({
transform: `translate(${tx.value}px, ${ty.value}px) scale(${scale.value}) rotate(${rotate.value}deg)`,
transformOrigin: 'center center'
}));
watch(
() => open.value,
(v) => {
if (v) {
portalLayer.bringToFront();
resetTransform();
}
}
);
onBeforeUnmount(() => {
portalLayer.release();
});
watch(
() => imageUrl.value,
(url) => {
if (!url || !open.value) return;
resetTransform();
}
);
function onKeydown(e) {
if (!open.value) return;
if (e.key === '+' || e.key === '=') zoomInCenter();
else if (e.key === '-' || e.key === '_') zoomOutCenter();
else if (e.key.toLowerCase() === 'r') rotateCW();
else if (e.key === '0') resetTransform();
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
async function copyImageToClipboard(url) {
if (!url) return;
const msg = toast.info('Downloading image...');
try {
const response = await webApiService.execute({
url,
method: 'GET'
});
if (response.status !== 200 || !response.data.startsWith('data:image/png')) {
const response = await webApiService.execute({ url, method: 'GET' });
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
throw new Error(`Error: ${response.data}`);
}
await navigator.clipboard.write([
new ClipboardItem({
'image/png': await (await fetch(response.data)).blob()
})
]);
const blob = await (await fetch(response.data)).blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
toast.success('Image copied to clipboard');
} catch (error) {
console.error('Error downloading image:', error);
new Noty({
type: 'error',
text: escapeTag(`Failed to download image. ${url}`)
}).show();
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
} finally {
toast.dismiss(msg);
}
}
async function downloadAndSaveImage(url, fileName) {
if (!url) {
return;
}
if (!url) return;
const msg = toast.info('Downloading image...');
try {
const response = await webApiService.execute({
url,
method: 'GET'
});
if (response.status !== 200 || !response.data.startsWith('data:image/png')) {
const response = await webApiService.execute({ url, method: 'GET' });
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
throw new Error(`Error: ${response.data}`);
}
const link = document.createElement('a');
link.href = response.data;
const fileId = extractFileId(url);
if (!fileName && fileId) {
fileName = `${fileId}.png`;
}
if (!fileName) {
fileName = `${url.split('/').pop()}.png`;
}
if (!fileName) {
fileName = 'image.png';
}
link.setAttribute('download', fileName);
let name = fileName;
if (!name && fileId) name = `${fileId}.png`;
if (!name) name = `${url.split('/').pop()}.png`;
if (!name) name = 'image.png';
link.setAttribute('download', name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Error downloading image:', error);
new Noty({
type: 'error',
text: escapeTag(`Failed to download image. ${url}`)
}).show();
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
} finally {
toast.dismiss(msg);
}
@@ -156,27 +334,12 @@
</script>
<style scoped>
.toolbar-icon:hover {
opacity: 1;
.x-viewer-img {
will-change: transform;
cursor: grab;
user-select: none;
}
.toolbar-icon {
cursor: pointer;
opacity: 0.8;
}
.fullscreen-image-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
}
.fullscreen-image {
max-width: 100%;
max-height: 100%;
}
:deep(.el-image__preview) {
display: none;
.x-viewer-img:active {
cursor: grabbing;
}
</style>

View File

@@ -1,246 +0,0 @@
<template>
<Popover v-model:open="visible">
<PopoverTrigger asChild>
<Button>
{{ t('nav_menu.icon_picker.pick_icon') }}
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" class="w-155">
<div class="icon-picker">
<InputGroupSearch
v-model="search"
class="icon-picker__search"
:placeholder="t('nav_menu.icon_picker.search_placeholder')" />
<el-scrollbar v-if="filteredCategories.length" height="600px" class="icon-picker__scroll">
<div v-for="category in filteredCategories" :key="category.name" class="icon-picker__category">
<div class="icon-picker__category-title">
{{ category.name }}
</div>
<div class="icon-picker__grid">
<div v-for="group in category.groups" :key="group.id" class="icon-picker__group">
<div class="icon-picker__group-label">
{{ group.label }}
</div>
<div class="icon-picker__variants">
<button
v-for="variant in group.variants"
:key="variant.className"
type="button"
class="icon-picker__variant"
:class="{ 'is-active': variant.className === modelValue }"
:title="group.tooltip"
@click="handleSelect(variant.className)">
<i :class="[variant.className, 'ri-2x']"></i>
</button>
</div>
</div>
</div>
</div>
</el-scrollbar>
<div v-else class="icon-picker__empty">{{ t('nav_menu.icon_picker.no_icon_found') }}</div>
</div>
</PopoverContent>
</Popover>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { InputGroupSearch } from '@/components/ui/input-group';
import { useI18n } from 'vue-i18n';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import remixIconTags from '../shared/constants/remixIconTags.json';
const { t } = useI18n();
defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const search = ref('');
const parseTags = (tagsText) =>
typeof tagsText === 'string'
? tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const formatLabel = (baseName) =>
baseName
.split('-')
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
const createGroup = (categoryName, baseName, tagsText) => {
const normalizedTags = parseTags(tagsText);
const label = formatLabel(baseName);
const variants = ['line', 'fill'].map((variant) => ({
className: `ri-${baseName}-${variant}`,
variant
}));
const searchText = [
baseName,
label,
...baseName.split('-'),
...normalizedTags,
...variants.map((variant) => variant.className),
'line',
'fill'
]
.join(' ')
.toLowerCase();
return {
id: `${categoryName}-${baseName}`,
label,
tooltip: normalizedTags.length ? `${label}${normalizedTags.join(', ')}` : label,
variants,
searchable: searchText
};
};
const categories = computed(() =>
Object.entries(remixIconTags)
.filter(([key]) => key !== '_comment')
.map(([name, icons]) => ({
name,
groups: Object.entries(icons || {}).map(([baseName, tags]) => createGroup(name, baseName, tags))
}))
);
const filteredCategories = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) {
return categories.value;
}
return categories.value
.map((category) => ({
name: category.name,
groups: category.groups.filter((group) => group.searchable.includes(query))
}))
.filter((category) => category.groups.length > 0);
});
const handleSelect = (className) => {
emit('update:modelValue', className);
visible.value = false;
};
watch(visible, (nextVisible) => {
if (!nextVisible) {
search.value = '';
}
});
</script>
<style scoped>
.icon-picker__trigger i {
font-size: 16px;
}
.icon-picker {
display: flex;
flex-direction: column;
gap: 8px;
height: 600px;
width: 100%;
}
.icon-picker__search {
flex-shrink: 0;
}
.icon-picker__scroll {
padding-right: 6px;
}
.icon-picker__category {
margin-bottom: 12px;
}
.icon-picker__category-title {
font-weight: 600;
margin-bottom: 6px;
}
.icon-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.icon-picker__group {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 4px;
align-items: center;
}
.icon-picker__group-label {
font-size: 12px;
font-weight: 600;
text-align: center;
color: var(--el-text-color-primary);
}
.icon-picker__variants {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.icon-picker__variant {
border: 1px solid transparent;
background: transparent;
cursor: pointer;
width: 84px;
height: 84px;
border-radius: 10px;
color: var(--el-text-color-regular);
transition:
color 0.2s ease,
background 0.2s ease,
transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.icon-picker__variant i {
color: inherit;
}
.icon-picker__variant:hover {
border-color: var(--el-color-primary);
background: var(--el-fill-color-light);
transform: translateY(-1px);
}
.icon-picker__variant.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.icon-picker__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>

View File

@@ -2,11 +2,11 @@
<div v-if="isVisible" class="inline-block">
<TooltipWrapper side="top" :content="t('dialog.user.info.launch_invite_tooltip')"
><Button
class="rounded-full w-6 h-6 text-muted-foreground hover:text-foreground"
class="rounded-full w-6 h-6 text-xs text-muted-foreground hover:text-foreground"
size="icon-sm"
variant="outline"
@click="confirm"
><Star />
><LogIn />
</Button>
</TooltipWrapper>
</div>
@@ -14,7 +14,7 @@
<script setup>
import { Button } from '@/components/ui/button';
import { Star } from 'lucide-vue-next';
import { LogIn } from 'lucide-vue-next';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';

View File

@@ -3,11 +3,7 @@
<div v-if="!text" class="transparent">-</div>
<div v-show="text" class="flex items-center">
<div v-if="region" :class="['flags', 'mr-1.5', region]"></div>
<TooltipWrapper
:content="`${t('dialog.new_instance.instance_id')}: #${instanceName}`"
:disabled="!instanceName || showInstanceIdInLocation"
:delay-duration="300"
side="top">
<template v-if="disableTooltip">
<div
:class="['x-location', { 'x-link': link && location !== 'private' && location !== 'offline' }]"
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
@@ -21,10 +17,37 @@
({{ groupName }})
</span>
</div>
</TooltipWrapper>
<TooltipWrapper v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<AlertTriangle :class="['inline-block', 'ml-5']" style="color: lightcoral" />
</TooltipWrapper>
<AlertTriangle v-if="isClosed" :class="['inline-block', 'ml-5']" style="color: lightcoral" />
</template>
<template v-else>
<TooltipWrapper
:content="`${t('dialog.new_instance.instance_id')}: #${instanceName}`"
:disabled="!instanceName || showInstanceIdInLocation"
:delay-duration="300"
side="top">
<div
:class="['x-location', { 'x-link': link && location !== 'private' && location !== 'offline' }]"
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
@click="handleShowWorldDialog">
<Loader2 :class="['is-loading']" class="mr-1" v-if="isTraveling" />
<span class="min-w-0 truncate">{{ text }}</span>
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1 whitespace-nowrap">{{
` · #${instanceName}`
}}</span>
<span
v-if="groupName"
class="ml-0.5 whitespace-nowrap x-link"
@click.stop="handleShowGroupDialog">
({{ groupName }})
</span>
</div>
</TooltipWrapper>
<TooltipWrapper v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<AlertTriangle :class="['inline-block', 'ml-5']" style="color: lightcoral" />
</TooltipWrapper>
</template>
<Lock v-if="strict" :class="['inline-block', 'ml-5']" />
</div>
</div>
@@ -71,6 +94,10 @@
type: Boolean,
default: true
},
disableTooltip: {
type: Boolean,
default: false
},
isOpenPreviousInstanceInfoDialog: {
type: Boolean,
default: false

View File

@@ -14,7 +14,7 @@
</template>
<script setup>
import { Lock, Unlock, AlertTriangle } from 'lucide-vue-next';
import { AlertTriangle, Lock, Unlock } from 'lucide-vue-next';
import { ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

View File

@@ -239,13 +239,13 @@
<CustomNavDialog
v-model:visible="customNavDialogVisible"
:layout="navLayout"
:default-folder-icon="DEFAULT_FOLDER_ICON"
@save="handleCustomNavSave"
@reset="handleCustomNavReset" />
</template>
<script setup>
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { ElMenu, ElMenuItem, ElPopover, ElSubMenu } from 'element-plus';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { dayjs } from 'element-plus';

View File

@@ -0,0 +1,100 @@
<script setup>
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: String, default: '' },
presets: {
type: Array,
default: () => []
},
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
emptyValue: { type: String, default: '' },
cols: { type: Number, default: 6 }
});
const emit = defineEmits(['update:modelValue', 'change']);
function normalizeHex(v) {
const s = String(v || '')
.trim()
.toLowerCase();
if (/^#[0-9a-f]{6}$/.test(s)) return s;
return '#ffffff';
}
const safeValue = computed(() => normalizeHex(props.modelValue));
const displayText = computed(() => (props.modelValue ? String(props.modelValue) : props.emptyValue));
function setColor(color) {
if (props.disabled) return;
emit('update:modelValue', color);
emit('change', color);
}
function onInput(e) {
if (props.disabled) return;
const v = e?.target?.value;
setColor(String(v || ''));
}
function clear() {
if (props.disabled || !props.clearable) return;
emit('update:modelValue', props.emptyValue);
emit('change', props.emptyValue);
}
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(1, props.cols)}, minmax(0, 1fr))`
}));
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="flex items-center gap-2 px-2" :disabled="disabled">
<span class="h-4 w-4 rounded border" :style="{ backgroundColor: safeValue }" />
<span class="font-mono text-xs opacity-80">
{{ displayText }}
</span>
<span v-if="clearable && modelValue" class="ml-1 opacity-60"></span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-56 p-3">
<div class="mb-3 grid gap-2" :style="gridStyle">
<button
v-for="color in presets"
:key="color"
type="button"
class="h-6 w-6 rounded border"
:style="{ backgroundColor: color }"
:disabled="disabled"
:aria-disabled="disabled ? 'true' : 'false'"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
safeValue === String(color).toLowerCase() ? 'ring-2 ring-offset-2' : ''
]"
@click="setColor(color)" />
</div>
<input
type="color"
class="h-8 w-full cursor-pointer border-none bg-transparent p-0"
:value="safeValue"
:disabled="disabled"
@input="onInput" />
<div v-if="clearable" class="mt-3 flex justify-end">
<Button variant="ghost" size="sm" :disabled="disabled" @click="clear"> Clear </Button>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -5,81 +5,83 @@
<DialogTitle>{{ t('dialog.favorite.header') }}</DialogTitle>
</DialogHeader>
<div v-loading="loading">
<span style="display: block; text-align: center">{{ t('dialog.favorite.vrchat_favorites') }}</span>
<template v-if="favoriteDialog.currentGroup && favoriteDialog.currentGroup.key">
<Button
variant="outline"
style="width: 100%; white-space: initial"
class="my-1"
@click="deleteFavoriteNoConfirm(favoriteDialog.objectId)">
<Check />{{ favoriteDialog.currentGroup.displayName }} ({{ favoriteDialog.currentGroup.count }} /
{{ favoriteDialog.currentGroup.capacity }})
</Button>
</template>
<template v-else>
<Button
variant="outline"
v-for="group in groups"
:key="group.key"
style="width: 100%; white-space: initial"
class="my-1"
@click="addFavorite(group)">
{{ group.displayName }} ({{ group.count }} / {{ group.capacity }})
</Button>
</template>
<span style="display: block; text-align: center">{{ t('dialog.favorite.vrchat_favorites') }}</span>
<template v-if="favoriteDialog.currentGroup && favoriteDialog.currentGroup.key">
<Button
variant="outline"
style="width: 100%; white-space: initial"
class="my-1"
@click="deleteFavoriteNoConfirm(favoriteDialog.objectId)">
<Check />{{ favoriteDialog.currentGroup.displayName }} ({{
favoriteDialog.currentGroup.count
}}
/ {{ favoriteDialog.currentGroup.capacity }})
</Button>
</template>
<template v-else>
<Button
variant="outline"
v-for="group in groups"
:key="group.key"
style="width: 100%; white-space: initial"
class="my-1"
@click="addFavorite(group)">
{{ group.displayName }} ({{ group.count }} / {{ group.capacity }})
</Button>
</template>
</div>
<div v-if="favoriteDialog.type === 'world'" style="margin-top: 20px">
<span style="display: block; text-align: center">{{ t('dialog.favorite.local_favorites') }}</span>
<template v-for="group in localWorldFavoriteGroups" :key="group">
<Button
variant="outline"
v-if="hasLocalWorldFavorite(favoriteDialog.objectId, group)"
style="width: 100%; white-space: initial"
class="my-1"
@click="removeLocalWorldFavorite(favoriteDialog.objectId, group)">
<Check />{{ group }} ({{ localWorldFavGroupLength(group) }})
</Button>
<Button
variant="outline"
v-else
style="width: 100%; white-space: initial"
class="my-1"
@click="addLocalWorldFavorite(favoriteDialog.objectId, group)">
{{ group }} ({{ localWorldFavGroupLength(group) }})
</Button>
</template>
<span style="display: block; text-align: center">{{ t('dialog.favorite.local_favorites') }}</span>
<template v-for="group in localWorldFavoriteGroups" :key="group">
<Button
variant="outline"
v-if="hasLocalWorldFavorite(favoriteDialog.objectId, group)"
style="width: 100%; white-space: initial"
class="my-1"
@click="removeLocalWorldFavorite(favoriteDialog.objectId, group)">
<Check />{{ group }} ({{ localWorldFavGroupLength(group) }})
</Button>
<Button
variant="outline"
v-else
style="width: 100%; white-space: initial"
class="my-1"
@click="addLocalWorldFavorite(favoriteDialog.objectId, group)">
{{ group }} ({{ localWorldFavGroupLength(group) }})
</Button>
</template>
</div>
<div v-if="favoriteDialog.type === 'avatar'" style="margin-top: 20px">
<span style="text-align: center">{{ t('dialog.favorite.local_avatar_favorites') }}</span>
<template v-for="group in localAvatarFavoriteGroups" :key="group">
<Button
variant="outline"
v-if="hasLocalAvatarFavorite(favoriteDialog.objectId, group)"
style="width: 100%; white-space: initial"
class="my-1"
@click="removeLocalAvatarFavorite(favoriteDialog.objectId, group)">
<Check />{{ group }} ({{ localAvatarFavGroupLength(group) }})
</Button>
<Button
variant="outline"
v-else
style="width: 100%; white-space: initial"
class="my-1"
:disabled="!isLocalUserVrcPlusSupporter"
@click="addLocalAvatarFavorite(favoriteDialog.objectId, group)">
{{ group }} ({{ localAvatarFavGroupLength(group) }})
</Button>
</template>
<span style="text-align: center">{{ t('dialog.favorite.local_avatar_favorites') }}</span>
<template v-for="group in localAvatarFavoriteGroups" :key="group">
<Button
variant="outline"
v-if="hasLocalAvatarFavorite(favoriteDialog.objectId, group)"
style="width: 100%; white-space: initial"
class="my-1"
@click="removeLocalAvatarFavorite(favoriteDialog.objectId, group)">
<Check />{{ group }} ({{ localAvatarFavGroupLength(group) }})
</Button>
<Button
variant="outline"
v-else
style="width: 100%; white-space: initial"
class="my-1"
:disabled="!isLocalUserVrcPlusSupporter"
@click="addLocalAvatarFavorite(favoriteDialog.objectId, group)">
{{ group }} ({{ localAvatarFavGroupLength(group) }})
</Button>
</template>
</div>
</DialogContent>
</Dialog>
</template>
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Check } from 'lucide-vue-next';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

View File

@@ -4,51 +4,20 @@
<DialogHeader>
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
</DialogHeader>
<div class="custom-nav-dialog__list" v-if="localLayout.length">
<div
v-for="(entry, index) in localLayout"
:key="entry.key || entry.id"
:class="['custom-nav-entry', `custom-nav-entry--${entry.type}`]">
<template v-if="entry.type === 'item'">
<div class="custom-nav-entry__info">
<i :class="definitionsMap.get(entry.key)?.icon"></i>
<span>{{ t(definitionsMap.get(entry.key)?.labelKey || entry.key) }}</span>
</div>
<div class="custom-nav-entry__controls">
<div class="custom-nav-entry__move">
<Button
class="rounded-full w-6 h-6 text-xs"
size="icon-sm"
variant="outline"
:disabled="index === 0"
@click="handleMoveEntry(index, -1)">
<i class="ri-arrow-up-line"></i>
</Button>
<Button
class="rounded-full w-6 h-6 text-xs"
size="icon-sm"
variant="outline"
:disabled="index === localLayout.length - 1"
@click="handleMoveEntry(index, 1)">
<i class="ri-arrow-down-line"></i>
</Button>
</div>
</div>
</template>
<template v-else>
<div class="custom-nav-entry__folder-header">
<div class="custom-nav-dialog__list" v-if="localLayout.length">
<div
v-for="(entry, index) in localLayout"
:key="entry.key || entry.id"
:class="['custom-nav-entry', `custom-nav-entry--${entry.type}`]">
<template v-if="entry.type === 'item'">
<div class="custom-nav-entry__info">
<i :class="entry.icon || defaultFolderIcon"></i>
<span>{{ entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder') }}</span>
<i :class="definitionsMap.get(entry.key)?.icon"></i>
<span>{{ t(definitionsMap.get(entry.key)?.labelKey || entry.key) }}</span>
</div>
<div class="custom-nav-entry__actions">
<Button size="icon-sm w-6 h-6 text-xs" variant="outline" @click="openFolderEditor(index)">
<i class="ri-edit-box-line"></i>
{{ t('nav_menu.custom_nav.edit_folder') }}
</Button>
<div class="custom-nav-entry__controls">
<div class="custom-nav-entry__move">
<Button
class="rounded-full text-xs w-6 h-6"
class="rounded-full w-6 h-6 text-xs"
size="icon-sm"
variant="outline"
:disabled="index === 0"
@@ -56,7 +25,7 @@
<i class="ri-arrow-up-line"></i>
</Button>
<Button
class="rounded-full text-xs w-6 h-6"
class="rounded-full w-6 h-6 text-xs"
size="icon-sm"
variant="outline"
:disabled="index === localLayout.length - 1"
@@ -65,54 +34,90 @@
</Button>
</div>
</div>
</div>
<div class="custom-nav-entry__folder-items">
<template v-if="entry.items?.length">
<Badge
v-for="key in entry.items"
:key="`${entry.id}-${key}`"
variant="outline"
class="custom-nav-entry__folder-tag">
{{ t(definitionsMap.get(key)?.labelKey || key) }}
</Badge>
</template>
<span v-else class="custom-nav-entry__folder-empty">
{{ t('nav_menu.custom_nav.folder_empty') }}
</span>
</div>
</template>
</template>
<template v-else>
<div class="custom-nav-entry__folder-header">
<div class="custom-nav-entry__info">
<i :class="entry.icon || defaultFolderIcon"></i>
<span>{{
entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder')
}}</span>
</div>
<div class="custom-nav-entry__actions">
<Button
size="icon-sm w-6 h-6 text-xs"
variant="outline"
@click="openFolderEditor(index)">
<i class="ri-edit-box-line"></i>
{{ t('nav_menu.custom_nav.edit_folder') }}
</Button>
<div class="custom-nav-entry__move">
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === 0"
@click="handleMoveEntry(index, -1)">
<i class="ri-arrow-up-line"></i>
</Button>
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === localLayout.length - 1"
@click="handleMoveEntry(index, 1)">
<i class="ri-arrow-down-line"></i>
</Button>
</div>
</div>
</div>
<div class="custom-nav-entry__folder-items">
<template v-if="entry.items?.length">
<Badge
v-for="key in entry.items"
:key="`${entry.id}-${key}`"
variant="outline"
class="custom-nav-entry__folder-tag">
{{ t(definitionsMap.get(key)?.labelKey || key) }}
</Badge>
</template>
<span v-else class="custom-nav-entry__folder-empty">
{{ t('nav_menu.custom_nav.folder_empty') }}
</span>
</div>
</template>
</div>
</div>
</div>
<el-alert
v-if="invalidFolders.length"
type="warning"
:closable="false"
:title="t('nav_menu.custom_nav.invalid_folder')" />
<!-- <el-alert
v-if="invalidFolders.length"
type="warning"
:closable="false"
:title="t('nav_menu.custom_nav.invalid_folder')" /> -->
<DialogFooter>
<div class="custom-nav-dialog__footer">
<div class="custom-nav-dialog__footer-left">
<Button variant="outline" @click="openFolderEditor()">
{{ t('nav_menu.custom_nav.add_folder') }}
</Button>
<Button variant="outline" @click="handleReset">
{{ t('nav_menu.custom_nav.restore_default') }}
</Button>
<div class="custom-nav-dialog__footer">
<div class="custom-nav-dialog__footer-left">
<Button variant="outline" @click="openFolderEditor()">
{{ t('nav_menu.custom_nav.add_folder') }}
</Button>
<Button variant="outline" @click="handleReset">
{{ t('nav_menu.custom_nav.restore_default') }}
</Button>
</div>
<div class="custom-nav-dialog__footer-right">
<Button variant="secondary" @click="handleClose">
{{ t('nav_menu.custom_nav.cancel') }}
</Button>
<Button :disabled="isSaveDisabled" @click="handleSave">
{{ t('nav_menu.custom_nav.save') }}
</Button>
</div>
</div>
<div class="custom-nav-dialog__footer-right">
<Button variant="secondary" @click="handleClose">
{{ t('nav_menu.custom_nav.cancel') }}
</Button>
<Button :disabled="isSaveDisabled" @click="handleSave">
{{ t('nav_menu.custom_nav.save') }}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="folderEditor.visible">
<DialogContent class="folder-editor-dialog">
<DialogContent class="folder-editor-dialog sm:max-w-[50vw]">
<DialogHeader>
<DialogTitle>
{{
@@ -122,113 +127,151 @@
}}
</DialogTitle>
</DialogHeader>
<div class="folder-editor">
<div class="folder-editor__form">
<InputGroupField
v-model="folderEditor.data.name"
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
<IconPicker v-model="folderEditor.data.icon" class="folder-editor__icon-picker" />
</div>
<div class="folder-editor__lists">
<div class="folder-editor__column">
<div class="folder-editor__column-title">
{{ t('nav_menu.custom_nav.folder_available') }}
</div>
<div v-if="!folderEditorAvailableItems.length" class="folder-editor__empty">
{{ t('nav_menu.custom_nav.folder_empty') }}
</div>
<el-scrollbar v-else always class="folder-editor__scroll">
<div v-for="item in folderEditorAvailableItems" :key="item.key" class="folder-editor__option">
<label class="folder-editor__option-label">
<Checkbox
:model-value="folderEditor.data.items.includes(item.key)"
@update:modelValue="(val) => toggleFolderItem(item.key, val)" />
<span>
<i :class="item.icon"></i>
{{ t(item.labelKey) }}
</span>
</label>
</div>
</el-scrollbar>
<div class="folder-editor">
<div class="folder-editor__form">
<InputGroupField
class="col-span-2"
v-model="folderEditor.data.name"
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
<InputGroupField
class="col-span-2"
v-model="folderEditor.data.icon"
:placeholder="t('nav_menu.custom_nav.folder_icon_placeholder')">
<template #trailing>
<HoverCard>
<HoverCardTrigger as-child>
<InputGroupButton
size="icon-xs"
:aria-label="t('nav_menu.custom_nav.folder_icon_placeholder')">
<LinkIcon class="size-3.5" />
</InputGroupButton>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="end" class="w-80">
<div class="text-sm leading-snug">
<div>
Find the icon you want on this site and paste its class name here, e.g.
<span class="font-mono">ri-arrow-left-up-line</span>
</div>
<div class="mt-2">
<a
class="x-link"
@click.prevent="openExternalLink('https://remixicon.com/')">
https://remixicon.com/
</a>
</div>
</div>
</HoverCardContent>
</HoverCard>
</template>
</InputGroupField>
</div>
<div class="folder-editor__column folder-editor__column--selected">
<div class="folder-editor__column-title">
{{ t('nav_menu.custom_nav.folder_selected') }}
</div>
<div v-if="!folderEditor.data.items.length" class="folder-editor__empty">
{{ t('nav_menu.custom_nav.folder_selected_empty') }}
</div>
<div
v-for="(key, index) in folderEditor.data.items"
:key="`selected-${key}`"
class="folder-editor__selected-item">
<div class="folder-editor__selected-label">
<i :class="definitionsMap.get(key)?.icon"></i>
<span>{{ t(definitionsMap.get(key)?.labelKey || key) }}</span>
<div class="folder-editor__lists">
<div class="folder-editor__column">
<div class="folder-editor__column-title">
{{ t('nav_menu.custom_nav.folder_available') }}
</div>
<div class="folder-editor__selected-actions">
<div class="custom-nav-entry__move">
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === 0"
@click="handleFolderItemMove(index, -1)">
<i class="ri-arrow-up-line"></i>
</Button>
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === folderEditor.data.items.length - 1"
@click="handleFolderItemMove(index, 1)">
<i class="ri-arrow-down-line"></i>
<div v-if="!folderEditorAvailableItems.length" class="folder-editor__empty">
{{ t('nav_menu.custom_nav.folder_empty') }}
</div>
<ScrollArea v-else type="always" class="folder-editor__scroll">
<div
v-for="item in folderEditorAvailableItems"
:key="item.key"
class="folder-editor__option">
<label class="folder-editor__option-label">
<Checkbox
:model-value="folderEditor.data.items.includes(item.key)"
@update:modelValue="(val) => toggleFolderItem(item.key, val)" />
<span>
<i :class="item.icon"></i>
{{ t(item.labelKey) }}
</span>
</label>
</div>
</ScrollArea>
</div>
<div class="folder-editor__column folder-editor__column--selected">
<div class="folder-editor__column-title">
{{ t('nav_menu.custom_nav.folder_selected') }}
</div>
<div v-if="!folderEditor.data.items.length" class="folder-editor__empty">
{{ t('nav_menu.custom_nav.folder_selected_empty') }}
</div>
<div
v-for="(key, index) in folderEditor.data.items"
:key="`selected-${key}`"
class="folder-editor__selected-item">
<div class="folder-editor__selected-label">
<i :class="definitionsMap.get(key)?.icon"></i>
<span>{{ t(definitionsMap.get(key)?.labelKey || key) }}</span>
</div>
<div class="folder-editor__selected-actions">
<div class="custom-nav-entry__move">
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === 0"
@click="handleFolderItemMove(index, -1)">
<i class="ri-arrow-up-line"></i>
</Button>
<Button
class="rounded-full text-xs w-6 h-6"
size="icon-sm"
variant="outline"
:disabled="index === folderEditor.data.items.length - 1"
@click="handleFolderItemMove(index, 1)">
<i class="ri-arrow-down-line"></i>
</Button>
</div>
<Button size="sm" variant="outline" @click="toggleFolderItem(key, false)">
{{ t('nav_menu.custom_nav.remove_from_folder') }}
</Button>
</div>
<Button size="sm" variant="outline" @click="toggleFolderItem(key, false)">
{{ t('nav_menu.custom_nav.remove_from_folder') }}
</Button>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<div class="folder-editor__footer">
<Button
variant="destructive"
v-if="folderEditor.isEditing"
:disabled="!canDeleteFolder"
@click="handleFolderEditorDelete">
{{ t('nav_menu.custom_nav.delete_folder') }}
</Button>
<div class="folder-editor__footer-spacer"></div>
<Button variant="secondary" class="mr-2" @click="closeFolderEditor">
{{ t('nav_menu.custom_nav.cancel') }}
</Button>
<Button :disabled="folderEditorSaveDisabled" @click="handleFolderEditorSave">
{{ t('nav_menu.custom_nav.save') }}
</Button>
</div>
<div class="folder-editor__footer">
<Button
variant="destructive"
v-if="folderEditor.isEditing"
:disabled="!canDeleteFolder"
@click="handleFolderEditorDelete">
{{ t('nav_menu.custom_nav.delete_folder') }}
</Button>
<div class="folder-editor__footer-spacer"></div>
<Button variant="secondary" class="mr-2" @click="closeFolderEditor">
{{ t('nav_menu.custom_nav.cancel') }}
</Button>
<Button :disabled="folderEditorSaveDisabled" @click="handleFolderEditorSave">
{{ t('nav_menu.custom_nav.save') }}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, reactive, ref, watch } from 'vue';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Button } from '@/components/ui/button';
import { Link as LinkIcon } from 'lucide-vue-next';
import { openExternalLink } from '@/shared/utils/common';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { InputGroupButton, InputGroupField } from '../ui/input-group';
import { Badge } from '../ui/badge';
import { Checkbox } from '../ui/checkbox';
import { InputGroupField } from '../ui/input-group';
import { ScrollArea } from '../ui/scroll-area';
import { navDefinitions } from '../../shared/constants/ui.js';
import IconPicker from '../IconPicker.vue';
// import IconPicker from '../IconPicker.vue';
const props = defineProps({
visible: {
@@ -240,8 +283,7 @@
default: () => []
},
defaultFolderIcon: {
type: String,
default: 'ri-menu-fold-line'
type: String
}
});
@@ -258,7 +300,7 @@
type: 'folder',
id: entry.id,
name: entry.name,
icon: entry.icon || props.defaultFolderIcon,
icon: entry.icon,
items: Array.isArray(entry.items) ? [...entry.items] : []
};
}
@@ -358,14 +400,14 @@
folderEditor.data = {
id: entry.id,
name: entry.name,
icon: entry.icon || props.defaultFolderIcon,
icon: entry.icon,
items: Array.isArray(entry.items) ? [...entry.items] : []
};
} else {
folderEditor.data = {
id: `custom-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 7)}`,
name: '',
icon: props.defaultFolderIcon,
icon: '',
items: []
};
}
@@ -399,6 +441,7 @@
const applyFolderChanges = () => {
const sanitizedItems = folderEditor.data.items.filter((key) => definitionsMap.value.has(key));
const sanitizedIcon = folderEditor.data.icon?.trim() || '';
const entries = [...localLayout.value];
if (folderEditor.isEditing) {
@@ -421,7 +464,7 @@
type: 'folder',
id: folderEditor.data.id,
name: folderEditor.data.name.trim(),
icon: folderEditor.data.icon || props.defaultFolderIcon,
icon: sanitizedIcon,
items: sanitizedItems
});
@@ -442,7 +485,7 @@
type: 'folder',
id: folderEditor.data.id,
name: folderEditor.data.name.trim(),
icon: folderEditor.data.icon || props.defaultFolderIcon,
icon: sanitizedIcon,
items: sanitizedItems
});

View File

@@ -6,64 +6,64 @@
</DialogHeader>
<div v-if="inviteGroupDialog.visible" v-loading="inviteGroupDialog.loading">
<span>{{ t('dialog.invite_to_group.description') }}</span>
<br />
<span>{{ t('dialog.invite_to_group.description') }}</span>
<br />
<div style="margin-top: 15px; width: 100%">
<VirtualCombobox
v-model="inviteGroupDialog.groupId"
:groups="groupPickerGroups"
:disabled="inviteGroupDialog.loading"
:placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
:search-placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="item.label"></span>
</div>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</div>
<div style="width: 100%; margin-top: 15px">
<VirtualCombobox
v-model="inviteGroupDialog.userIds"
:groups="friendPickerGroups"
multiple
:disabled="inviteGroupDialog.loading"
:placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
:search-placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
:clearable="true">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<template v-if="item.user">
<div class="avatar" :class="userStatusClass(item.user)">
<img :src="userImage(item.user)" loading="lazy" />
<div style="margin-top: 15px; width: 100%">
<VirtualCombobox
v-model="inviteGroupDialog.groupId"
:groups="groupPickerGroups"
:disabled="inviteGroupDialog.loading"
:placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
:search-placeholder="t('dialog.invite_to_group.choose_group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
</div>
<div class="detail">
<span
class="name"
:style="{ color: item.user.$userColour }"
v-text="item.user.displayName"></span>
<span class="name" v-text="item.label"></span>
</div>
</template>
<template v-else>
<span v-text="item.label"></span>
</template>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</div>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</div>
<div style="width: 100%; margin-top: 15px">
<VirtualCombobox
v-model="inviteGroupDialog.userIds"
:groups="friendPickerGroups"
multiple
:disabled="inviteGroupDialog.loading"
:placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
:search-placeholder="t('dialog.invite_to_group.choose_friends_placeholder')"
:clearable="true">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<template v-if="item.user">
<div class="avatar" :class="userStatusClass(item.user)">
<img :src="userImage(item.user)" loading="lazy" />
</div>
<div class="detail">
<span
class="name"
:style="{ color: item.user.$userColour }"
v-text="item.user.displayName"></span>
</div>
</template>
<template v-else>
<span v-text="item.label"></span>
</template>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</div>
</div>
<DialogFooter>
@@ -80,9 +80,9 @@
</template>
<script setup>
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Check as CheckIcon } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';

View File

@@ -5,69 +5,69 @@
<DialogTitle>{{ t('dialog.launch.header') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('dialog.launch.header') }}</DialogDescription>
</DialogHeader>
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.launch.url') }}</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.url"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.url)"
><Copy
/></Button>
</TooltipWrapper>
</FieldContent>
</Field>
<Field v-if="launchDialog.shortUrl">
<FieldLabel>
<span class="flex items-center gap-1">
<span>{{ t('dialog.launch.short_url') }}</span>
<TooltipWrapper side="top" :content="t('dialog.launch.short_url_notice')">
<AlertTriangle />
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.launch.url') }}</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.url"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.url)"
><Copy
/></Button>
</TooltipWrapper>
</span>
</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.shortUrl"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.shortUrl)"
><Copy
/></Button>
</TooltipWrapper>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.launch.location') }}</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.location"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.location)"
><Copy
/></Button>
</TooltipWrapper>
</FieldContent>
</Field>
</FieldGroup>
</FieldContent>
</Field>
<Field v-if="launchDialog.shortUrl">
<FieldLabel>
<span class="flex items-center gap-1">
<span>{{ t('dialog.launch.short_url') }}</span>
<TooltipWrapper side="top" :content="t('dialog.launch.short_url_notice')">
<AlertTriangle />
</TooltipWrapper>
</span>
</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.shortUrl"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.shortUrl)"
><Copy
/></Button>
</TooltipWrapper>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.launch.location') }}</FieldLabel>
<FieldContent class="flex-row items-center gap-2">
<InputGroupField
v-model="launchDialog.location"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
<TooltipWrapper side="right" :content="t('dialog.launch.copy_tooltip')">
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="copyInstanceMessage(launchDialog.location)"
><Copy
/></Button>
</TooltipWrapper>
</FieldContent>
</Field>
</FieldGroup>
<DialogFooter>
<Button
class="mr-1.5"
@@ -129,8 +129,14 @@
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -138,6 +144,7 @@
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { AlertTriangle, Copy, MoreHorizontal } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';

View File

@@ -6,37 +6,37 @@
</DialogHeader>
<div v-if="moderateGroupDialog.visible">
<div class="x-friend-item" style="cursor: default">
<div class="avatar">
<img :src="userImage(moderateGroupDialog.userObject)" loading="lazy" />
<div class="x-friend-item" style="cursor: default">
<div class="avatar">
<img :src="userImage(moderateGroupDialog.userObject)" loading="lazy" />
</div>
<div class="detail">
<span
v-if="moderateGroupDialog.userObject.id"
class="name"
:style="{ color: moderateGroupDialog.userObject.$userColour }"
v-text="moderateGroupDialog.userObject.displayName"></span>
<span v-else v-text="moderateGroupDialog.userId"></span>
</div>
</div>
<div class="detail">
<span
v-if="moderateGroupDialog.userObject.id"
class="name"
:style="{ color: moderateGroupDialog.userObject.$userColour }"
v-text="moderateGroupDialog.userObject.displayName"></span>
<span v-else v-text="moderateGroupDialog.userId"></span>
</div>
</div>
<div style="margin-top: 15px; width: 100%">
<VirtualCombobox
:model-value="moderateGroupDialog.groupId"
@update:modelValue="setGroupId"
:groups="groupPickerGroups"
:placeholder="t('dialog.moderate_group.choose_group_placeholder')"
:search-placeholder="t('dialog.moderate_group.choose_group_placeholder')"
:close-on-select="true">
<template #item="{ item, selected }">
<div class="flex w-full items-center gap-2">
<img :src="item.iconUrl" loading="lazy" class="size-5 rounded-sm" />
<span class="truncate text-sm" v-text="item.label"></span>
<span v-if="selected" class="ml-auto opacity-70"></span>
</div>
</template>
</VirtualCombobox>
</div>
<div style="margin-top: 15px; width: 100%">
<VirtualCombobox
:model-value="moderateGroupDialog.groupId"
@update:modelValue="setGroupId"
:groups="groupPickerGroups"
:placeholder="t('dialog.moderate_group.choose_group_placeholder')"
:search-placeholder="t('dialog.moderate_group.choose_group_placeholder')"
:close-on-select="true">
<template #item="{ item, selected }">
<div class="flex w-full items-center gap-2">
<img :src="item.iconUrl" loading="lazy" class="size-5 rounded-sm" />
<span class="truncate text-sm" v-text="item.label"></span>
<span v-if="selected" class="ml-auto opacity-70"></span>
</div>
</template>
</VirtualCombobox>
</div>
</div>
<DialogFooter>
@@ -54,9 +54,9 @@
</template>
<script setup>
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

View File

@@ -5,208 +5,429 @@
<DialogTitle>{{ t('dialog.new_instance.header') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('dialog.new_instance.header') }}</DialogDescription>
</DialogHeader>
<TabsUnderline
v-model="newInstanceDialog.selectedTab"
:items="newInstanceTabs"
:unmount-on-hide="false"
@update:modelValue="newInstanceTabClick">
<template #Normal>
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.accessType"
@update:model-value="
(value) => {
newInstanceDialog.accessType = value;
buildInstance();
}
">
<ToggleGroupItem value="public">{{
t('dialog.new_instance.access_type_public')
}}</ToggleGroupItem>
<ToggleGroupItem value="group">{{
t('dialog.new_instance.access_type_group')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends+">{{
t('dialog.new_instance.access_type_friend_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends">{{
t('dialog.new_instance.access_type_friend')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite+">{{
t('dialog.new_instance.access_type_invite_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite">{{
t('dialog.new_instance.access_type_invite')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.groupAccessType"
@update:model-value="
(value) => {
newInstanceDialog.groupAccessType = value;
buildInstance();
}
">
<ToggleGroupItem
value="members"
:disabled="
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-open-create')
"
>{{ t('dialog.new_instance.group_access_type_members') }}</ToggleGroupItem
>
<ToggleGroupItem
value="plus"
:disabled="
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-plus-create')
"
>{{ t('dialog.new_instance.group_access_type_plus') }}</ToggleGroupItem
>
<ToggleGroupItem
value="public"
<TabsUnderline
v-model="newInstanceDialog.selectedTab"
:items="newInstanceTabs"
:unmount-on-hide="false"
@update:modelValue="newInstanceTabClick">
<template #Normal>
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.accessType"
@update:model-value="
(value) => {
newInstanceDialog.accessType = value;
buildInstance();
}
">
<ToggleGroupItem value="public">{{
t('dialog.new_instance.access_type_public')
}}</ToggleGroupItem>
<ToggleGroupItem value="group">{{
t('dialog.new_instance.access_type_group')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends+">{{
t('dialog.new_instance.access_type_friend_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends">{{
t('dialog.new_instance.access_type_friend')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite+">{{
t('dialog.new_instance.access_type_invite_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite">{{
t('dialog.new_instance.access_type_invite')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.groupAccessType"
@update:model-value="
(value) => {
newInstanceDialog.groupAccessType = value;
buildInstance();
}
">
<ToggleGroupItem
value="members"
:disabled="
!hasGroupPermission(
newInstanceDialog.groupRef,
'group-instance-open-create'
)
"
>{{ t('dialog.new_instance.group_access_type_members') }}</ToggleGroupItem
>
<ToggleGroupItem
value="plus"
:disabled="
!hasGroupPermission(
newInstanceDialog.groupRef,
'group-instance-plus-create'
)
"
>{{ t('dialog.new_instance.group_access_type_plus') }}</ToggleGroupItem
>
<ToggleGroupItem
value="public"
:disabled="
!hasGroupPermission(
newInstanceDialog.groupRef,
'group-instance-public-create'
) || newInstanceDialog.groupRef.privacy === 'private'
"
>{{ t('dialog.new_instance.group_access_type_public') }}</ToggleGroupItem
>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.region"
@update:model-value="
(value) => {
newInstanceDialog.region = value;
buildInstance();
}
">
<ToggleGroupItem value="US West">{{
t('dialog.new_instance.region_usw')
}}</ToggleGroupItem>
<ToggleGroupItem value="US East">{{
t('dialog.new_instance.region_use')
}}</ToggleGroupItem>
<ToggleGroupItem value="Europe">{{
t('dialog.new_instance.region_eu')
}}</ToggleGroupItem>
<ToggleGroupItem value="Japan">{{
t('dialog.new_instance.region_jp')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.queueEnabled') }}</FieldLabel>
<FieldContent>
<Checkbox v-model="newInstanceDialog.queueEnabled" @update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
<FieldContent>
<Checkbox
v-model="newInstanceDialog.ageGate"
:disabled="
!hasGroupPermission(
newInstanceDialog.groupRef,
'group-instance-public-create'
) || newInstanceDialog.groupRef.privacy === 'private'
'group-instance-age-gated-create'
)
"
>{{ t('dialog.new_instance.group_access_type_public') }}</ToggleGroupItem
>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.region"
@update:model-value="
(value) => {
newInstanceDialog.region = value;
buildInstance();
}
">
<ToggleGroupItem value="US West">{{
t('dialog.new_instance.region_usw')
}}</ToggleGroupItem>
<ToggleGroupItem value="US East">{{
t('dialog.new_instance.region_use')
}}</ToggleGroupItem>
<ToggleGroupItem value="Europe">{{
t('dialog.new_instance.region_eu')
}}</ToggleGroupItem>
<ToggleGroupItem value="Japan">{{
t('dialog.new_instance.region_jp')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.queueEnabled') }}</FieldLabel>
<FieldContent>
<Checkbox v-model="newInstanceDialog.queueEnabled" @update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
<FieldContent>
<Checkbox
v-model="newInstanceDialog.ageGate"
:disabled="
!hasGroupPermission(newInstanceDialog.groupRef, 'group-instance-age-gated-create')
"
@update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.display_name') }}</FieldLabel>
<FieldContent>
<InputGroupField
:disabled="!isLocalUserVrcPlusSupporter"
v-model="newInstanceDialog.displayName"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
@change="buildInstance" />
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.groupId"
:groups="normalGroupPickerGroups"
:placeholder="t('dialog.new_instance.group_placeholder')"
:search-placeholder="t('dialog.new_instance.group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
@update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.display_name') }}</FieldLabel>
<FieldContent>
<InputGroupField
:disabled="!isLocalUserVrcPlusSupporter"
v-model="newInstanceDialog.displayName"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
@change="buildInstance" />
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.groupId"
:groups="normalGroupPickerGroups"
:placeholder="t('dialog.new_instance.group_placeholder')"
:search-placeholder="t('dialog.new_instance.group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="item.label"></span>
</div>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
<div class="detail">
<span class="name" v-text="item.label"></span>
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field
v-if="
newInstanceDialog.accessType === 'group' &&
newInstanceDialog.groupAccessType === 'members'
"
class="items-start">
<FieldLabel>{{ t('dialog.new_instance.roles') }}</FieldLabel>
<FieldContent>
<Select
multiple
:model-value="
Array.isArray(newInstanceDialog.roleIds) ? newInstanceDialog.roleIds : []
"
@update:modelValue="handleRoleIdsChange">
<SelectTrigger size="sm" class="w-full">
<SelectValue>
<span class="truncate">
{{ selectedRoleSummary || t('dialog.new_instance.role_placeholder') }}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="role in newInstanceDialog.selectedGroupRoles"
:key="role.id"
:value="role.id">
{{ role.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FieldContent>
</Field>
<template v-if="newInstanceDialog.instanceCreated">
<Field>
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.location"
size="sm"
readonly
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.url') }}</FieldLabel>
<FieldContent>
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
</FieldContent>
</Field>
</template>
</FieldGroup>
</template>
<template #Legacy>
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.accessType"
@update:model-value="
(value) => {
newInstanceDialog.accessType = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="public">{{
t('dialog.new_instance.access_type_public')
}}</ToggleGroupItem>
<ToggleGroupItem value="group">{{
t('dialog.new_instance.access_type_group')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends+">{{
t('dialog.new_instance.access_type_friend_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends">{{
t('dialog.new_instance.access_type_friend')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite+">{{
t('dialog.new_instance.access_type_invite_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite">{{
t('dialog.new_instance.access_type_invite')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.groupAccessType"
@update:model-value="
(value) => {
newInstanceDialog.groupAccessType = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="members">{{
t('dialog.new_instance.group_access_type_members')
}}</ToggleGroupItem>
<ToggleGroupItem value="plus">{{
t('dialog.new_instance.group_access_type_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="public">{{
t('dialog.new_instance.group_access_type_public')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.region"
@update:model-value="
(value) => {
newInstanceDialog.region = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="US West">{{
t('dialog.new_instance.region_usw')
}}</ToggleGroupItem>
<ToggleGroupItem value="US East">{{
t('dialog.new_instance.region_use')
}}</ToggleGroupItem>
<ToggleGroupItem value="Europe">{{
t('dialog.new_instance.region_eu')
}}</ToggleGroupItem>
<ToggleGroupItem value="Japan">{{
t('dialog.new_instance.region_jp')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
<FieldContent>
<Checkbox v-model="newInstanceDialog.ageGate" @update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.world_id') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.worldId"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
@change="buildLegacyInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.instance_id') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.instanceName"
:placeholder="t('dialog.new_instance.instance_id_placeholder')"
size="sm"
@change="buildLegacyInstance" />
</FieldContent>
</Field>
<Field
v-if="
newInstanceDialog.selectedTab === 'Legacy' &&
newInstanceDialog.accessType !== 'public' &&
newInstanceDialog.accessType !== 'group'
"
class="items-start">
<FieldLabel>{{ t('dialog.new_instance.instance_creator') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.userId"
:groups="creatorPickerGroups"
:placeholder="t('dialog.new_instance.instance_creator_placeholder')"
:search-placeholder="t('dialog.new_instance.instance_creator_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildLegacyInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<template v-if="item.user">
<div class="avatar" :class="userStatusClass(item.user)">
<img :src="userImage(item.user)" loading="lazy" />
</div>
<div class="detail">
<span
class="name"
:style="{ color: item.user.$userColour }"
v-text="item.user.displayName"></span>
</div>
</template>
<template v-else>
<span v-text="item.label"></span>
</template>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field
v-if="
newInstanceDialog.accessType === 'group' && newInstanceDialog.groupAccessType === 'members'
"
class="items-start">
<FieldLabel>{{ t('dialog.new_instance.roles') }}</FieldLabel>
<FieldContent>
<Select
multiple
:model-value="Array.isArray(newInstanceDialog.roleIds) ? newInstanceDialog.roleIds : []"
@update:modelValue="handleRoleIdsChange">
<SelectTrigger size="sm" class="w-full">
<SelectValue>
<span class="truncate">
{{ selectedRoleSummary || t('dialog.new_instance.role_placeholder') }}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="role in newInstanceDialog.selectedGroupRoles"
:key="role.id"
:value="role.id">
{{ role.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FieldContent>
</Field>
<template v-if="newInstanceDialog.instanceCreated">
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.groupId"
:groups="legacyGroupPickerGroups"
:placeholder="t('dialog.new_instance.group_placeholder')"
:search-placeholder="t('dialog.new_instance.group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildLegacyInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="item.label"></span>
</div>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
<FieldContent>
@@ -223,220 +444,49 @@
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
</FieldContent>
</Field>
</template>
</FieldGroup>
</template>
<template #Legacy>
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ t('dialog.new_instance.access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.accessType"
@update:model-value="
(value) => {
newInstanceDialog.accessType = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="public">{{
t('dialog.new_instance.access_type_public')
}}</ToggleGroupItem>
<ToggleGroupItem value="group">{{
t('dialog.new_instance.access_type_group')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends+">{{
t('dialog.new_instance.access_type_friend_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="friends">{{
t('dialog.new_instance.access_type_friend')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite+">{{
t('dialog.new_instance.access_type_invite_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="invite">{{
t('dialog.new_instance.access_type_invite')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_access_type') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.groupAccessType"
@update:model-value="
(value) => {
newInstanceDialog.groupAccessType = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="members">{{
t('dialog.new_instance.group_access_type_members')
}}</ToggleGroupItem>
<ToggleGroupItem value="plus">{{
t('dialog.new_instance.group_access_type_plus')
}}</ToggleGroupItem>
<ToggleGroupItem value="public">{{
t('dialog.new_instance.group_access_type_public')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.region') }}</FieldLabel>
<FieldContent>
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
:model-value="newInstanceDialog.region"
@update:model-value="
(value) => {
newInstanceDialog.region = value;
buildLegacyInstance();
}
">
<ToggleGroupItem value="US West">{{
t('dialog.new_instance.region_usw')
}}</ToggleGroupItem>
<ToggleGroupItem value="US East">{{
t('dialog.new_instance.region_use')
}}</ToggleGroupItem>
<ToggleGroupItem value="Europe">{{
t('dialog.new_instance.region_eu')
}}</ToggleGroupItem>
<ToggleGroupItem value="Japan">{{
t('dialog.new_instance.region_jp')
}}</ToggleGroupItem>
</ToggleGroup>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.ageGate') }}</FieldLabel>
<FieldContent>
<Checkbox v-model="newInstanceDialog.ageGate" @update:modelValue="buildInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.world_id') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.worldId"
size="sm"
@click="$event.target.tagName === 'INPUT' && $event.target.select()"
@change="buildLegacyInstance" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.instance_id') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.instanceName"
:placeholder="t('dialog.new_instance.instance_id_placeholder')"
size="sm"
@change="buildLegacyInstance" />
</FieldContent>
</Field>
<Field
v-if="
newInstanceDialog.selectedTab === 'Legacy' &&
newInstanceDialog.accessType !== 'public' &&
newInstanceDialog.accessType !== 'group'
</FieldGroup>
</template>
</TabsUnderline>
<DialogFooter v-if="newInstanceDialog.selectedTab === 'Normal'">
<template v-if="newInstanceDialog.instanceCreated">
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
t('dialog.new_instance.copy_url')
}}</Button>
<Button variant="outline" class="mr-2" @click="selfInvite(newInstanceDialog.location)">{{
t('dialog.new_instance.self_invite')
}}</Button>
<Button
variant="outline"
class="mr-2"
:disabled="
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
newInstanceDialog.userId !== currentUser.id
"
class="items-start">
<FieldLabel>{{ t('dialog.new_instance.instance_creator') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.userId"
:groups="creatorPickerGroups"
:placeholder="t('dialog.new_instance.instance_creator_placeholder')"
:search-placeholder="t('dialog.new_instance.instance_creator_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildLegacyInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<template v-if="item.user">
<div class="avatar" :class="userStatusClass(item.user)">
<img :src="userImage(item.user)" loading="lazy" />
</div>
<div class="detail">
<span
class="name"
:style="{ color: item.user.$userColour }"
v-text="item.user.displayName"></span>
</div>
</template>
<template v-else>
<span v-text="item.label"></span>
</template>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field v-if="newInstanceDialog.accessType === 'group'">
<FieldLabel>{{ t('dialog.new_instance.group_id') }}</FieldLabel>
<FieldContent>
<VirtualCombobox
v-model="newInstanceDialog.groupId"
:groups="legacyGroupPickerGroups"
:placeholder="t('dialog.new_instance.group_placeholder')"
:search-placeholder="t('dialog.new_instance.group_placeholder')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true"
@change="buildLegacyInstance">
<template #item="{ item, selected }">
<div class="x-friend-item flex w-full items-center">
<div class="avatar">
<img :src="item.iconUrl" loading="lazy" />
</div>
<div class="detail">
<span class="name" v-text="item.label"></span>
</div>
<CheckIcon
:class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</div>
</template>
</VirtualCombobox>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.location') }}</FieldLabel>
<FieldContent>
<InputGroupField
v-model="newInstanceDialog.location"
size="sm"
readonly
@click="$event.target.tagName === 'INPUT' && $event.target.select()" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('dialog.new_instance.url') }}</FieldLabel>
<FieldContent>
<InputGroupField v-model="newInstanceDialog.url" size="sm" readonly />
</FieldContent>
</Field>
</FieldGroup>
</template>
</TabsUnderline>
<DialogFooter v-if="newInstanceDialog.selectedTab === 'Normal'">
<template v-if="newInstanceDialog.instanceCreated">
@click="showInviteDialog(newInstanceDialog.location)"
>{{ t('dialog.new_instance.invite') }}</Button
>
<template v-if="canOpenInstanceInGame">
<Button
variant="secondary"
class="mr-2"
@click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)"
>{{ t('dialog.new_instance.launch') }}</Button
>
<Button @click="handleAttachGame(newInstanceDialog.location, newInstanceDialog.shortName)">
{{ t('dialog.new_instance.open_ingame') }}
</Button>
</template>
<template v-else>
<Button @click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)">{{
t('dialog.new_instance.launch')
}}</Button>
</template>
</template>
<template v-else>
<Button @click="handleCreateNewInstance">{{ t('dialog.new_instance.create_instance') }}</Button>
</template>
</DialogFooter>
<DialogFooter v-else-if="newInstanceDialog.selectedTab === 'Legacy'">
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
t('dialog.new_instance.copy_url')
}}</Button>
@@ -445,7 +495,6 @@
}}</Button>
<Button
variant="outline"
class="mr-2"
:disabled="
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
newInstanceDialog.userId !== currentUser.id
@@ -469,44 +518,7 @@
t('dialog.new_instance.launch')
}}</Button>
</template>
</template>
<template v-else>
<Button @click="handleCreateNewInstance">{{ t('dialog.new_instance.create_instance') }}</Button>
</template>
</DialogFooter>
<DialogFooter v-else-if="newInstanceDialog.selectedTab === 'Legacy'">
<Button variant="outline" class="mr-2" @click="copyInstanceUrl(newInstanceDialog.location)">{{
t('dialog.new_instance.copy_url')
}}</Button>
<Button variant="outline" class="mr-2" @click="selfInvite(newInstanceDialog.location)">{{
t('dialog.new_instance.self_invite')
}}</Button>
<Button
variant="outline"
:disabled="
(newInstanceDialog.accessType === 'friends' || newInstanceDialog.accessType === 'invite') &&
newInstanceDialog.userId !== currentUser.id
"
@click="showInviteDialog(newInstanceDialog.location)"
>{{ t('dialog.new_instance.invite') }}</Button
>
<template v-if="canOpenInstanceInGame">
<Button
variant="secondary"
class="mr-2"
@click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)"
>{{ t('dialog.new_instance.launch') }}</Button
>
<Button @click="handleAttachGame(newInstanceDialog.location, newInstanceDialog.shortName)">
{{ t('dialog.new_instance.open_ingame') }}
</Button>
</template>
<template v-else>
<Button @click="showLaunchDialog(newInstanceDialog.location, newInstanceDialog.shortName)">{{
t('dialog.new_instance.launch')
}}</Button>
</template>
</DialogFooter>
</DialogFooter>
</DialogContent>
<InviteDialog :invite-dialog="inviteDialog" @closeInviteDialog="closeInviteDialog" />
@@ -514,8 +526,15 @@
</template>
<script setup>
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Check as CheckIcon } from 'lucide-vue-next';

View File

@@ -4,79 +4,79 @@
<DialogHeader>
<DialogTitle>{{ t('dialog.boop_dialog.header') }}</DialogTitle>
</DialogHeader>
<span>{{ displayName }}</span>
<span>{{ displayName }}</span>
<br />
<br />
<br />
<br />
<div v-if="sendBoopDialog.visible" style="width: 100%">
<VirtualCombobox
v-model="emojiModel"
:groups="emojiPickerGroups"
:placeholder="t('dialog.boop_dialog.select_default_emoji')"
:search-placeholder="t('dialog.boop_dialog.select_default_emoji')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true">
<template #item="{ item, selected }">
<span v-text="item.label"></span>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</template>
</VirtualCombobox>
</div>
<div v-if="sendBoopDialog.visible" style="width: 100%">
<VirtualCombobox
v-model="emojiModel"
:groups="emojiPickerGroups"
:placeholder="t('dialog.boop_dialog.select_default_emoji')"
:search-placeholder="t('dialog.boop_dialog.select_default_emoji')"
:clearable="true"
:close-on-select="true"
:deselect-on-reselect="true">
<template #item="{ item, selected }">
<span v-text="item.label"></span>
<CheckIcon :class="['ml-auto size-4', selected ? 'opacity-100' : 'opacity-0']" />
</template>
</VirtualCombobox>
</div>
<br />
<br />
<br />
<br />
<div
v-if="isLocalUserVrcPlusSupporter"
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 15px;
margin-top: 10px;
max-height: 600px;
overflow-y: auto;
">
<div
v-for="image in emojiTable"
:key="image.id"
:class="image.id === fileId ? 'x-image-selected' : ''"
style="cursor: pointer; border: 1px solid transparent; border-radius: 8px"
@click="fileId = image.id">
v-if="isLocalUserVrcPlusSupporter"
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 15px;
margin-top: 10px;
max-height: 600px;
overflow-y: auto;
">
<div
v-if="
image.versions &&
image.versions.length > 0 &&
image.versions[image.versions.length - 1].file.url
"
class="x-popover-image"
style="padding: 8px">
<Emoji :imageUrl="image.versions[image.versions.length - 1].file.url" :size="100"></Emoji>
v-for="image in emojiTable"
:key="image.id"
:class="image.id === fileId ? 'x-image-selected' : ''"
style="cursor: pointer; border: 1px solid transparent; border-radius: 8px"
@click="fileId = image.id">
<div
v-if="
image.versions &&
image.versions.length > 0 &&
image.versions[image.versions.length - 1].file.url
"
class="x-popover-image"
style="padding: 8px">
<Emoji :imageUrl="image.versions[image.versions.length - 1].file.url" :size="100"></Emoji>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button size="sm" variant="outline" class="mr-2" @click="showGalleryPage">{{
t('dialog.boop_dialog.emoji_manager')
}}</Button>
<Button size="sm" variant="secondary" class="mr-2" @click="closeDialog">{{
t('dialog.boop_dialog.cancel')
}}</Button>
<Button size="sm" :disabled="!sendBoopDialog.userId" @click="sendBoop">{{
t('dialog.boop_dialog.send')
}}</Button>
<Button size="sm" variant="outline" class="mr-2" @click="showGalleryPage">{{
t('dialog.boop_dialog.emoji_manager')
}}</Button>
<Button size="sm" variant="secondary" class="mr-2" @click="closeDialog">{{
t('dialog.boop_dialog.cancel')
}}</Button>
<Button size="sm" :disabled="!sendBoopDialog.userId" @click="sendBoop">{{
t('dialog.boop_dialog.send')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Check as CheckIcon } from 'lucide-vue-next';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

View File

@@ -64,8 +64,7 @@
</template>
<Location
:location="userDialog.ref.location"
:traveling="userDialog.ref.travelingToLocation"
style="display: block; margin-top: 5px" />
:traveling="userDialog.ref.travelingToLocation" />
</div>
<div class="x-friend-list" style="flex: 1; margin-top: 10px; max-height: 150px">
<div

View File

@@ -5,86 +5,86 @@
<DialogTitle>{{ t('dialog.vrcx_updater.header') }}</DialogTitle>
</DialogHeader>
<div v-loading="checkingForVRCXUpdate" style="margin-top: 15px">
<template v-if="updateInProgress">
<Progress :model-value="updateProgress" class="w-full" />
<div class="mt-2 text-xs" v-text="updateProgressText()"></div>
<br />
</template>
<template v-else>
<div v-if="VRCXUpdateDialog.updatePending" style="margin-bottom: 15px">
<span>{{ pendingVRCXInstall }}</span>
<template v-if="updateInProgress">
<Progress :model-value="updateProgress" class="w-full" />
<div class="mt-2 text-xs" v-text="updateProgressText()"></div>
<br />
<span>{{ t('dialog.vrcx_updater.ready_for_update') }}</span>
</div>
<Tabs :model-value="branch" class="w-full" @update:modelValue="handleBranchChange">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="Stable">{{ t('dialog.vrcx_updater.branch_stable') }}</TabsTrigger>
<TabsTrigger value="Nightly">{{ t('dialog.vrcx_updater.branch_nightly') }}</TabsTrigger>
</TabsList>
<TabsContent value="Nightly">
<Alert variant="destructive">
<AlertCircle class="text-muted-foreground" />
<AlertTitle>{{ t('dialog.vrcx_updater.nightly_title') }}</AlertTitle>
<AlertDescription>
{{ t('dialog.vrcx_updater.nightly_notice') }}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<FieldGroup class="mt-3">
<Field>
<FieldLabel>{{ t('dialog.vrcx_updater.release') }}</FieldLabel>
<FieldContent>
<Select
:model-value="VRCXUpdateDialog.release"
@update:modelValue="(v) => (VRCXUpdateDialog.release = v)">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in VRCXUpdateDialog.releases"
:key="item.name"
:value="item.name">
{{ item.tag_name }}
</SelectItem>
</SelectContent>
</Select>
</FieldContent>
</Field>
</FieldGroup>
<div
v-if="!VRCXUpdateDialog.updatePending && VRCXUpdateDialog.release === appVersion"
class="mt-3 text-xs text-muted-foreground">
<span>{{ t('dialog.vrcx_updater.latest_version') }}</span>
</div>
</template>
</template>
<template v-else>
<div v-if="VRCXUpdateDialog.updatePending" style="margin-bottom: 15px">
<span>{{ pendingVRCXInstall }}</span>
<br />
<span>{{ t('dialog.vrcx_updater.ready_for_update') }}</span>
</div>
<Tabs :model-value="branch" class="w-full" @update:modelValue="handleBranchChange">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="Stable">{{ t('dialog.vrcx_updater.branch_stable') }}</TabsTrigger>
<TabsTrigger value="Nightly">{{ t('dialog.vrcx_updater.branch_nightly') }}</TabsTrigger>
</TabsList>
<TabsContent value="Nightly">
<Alert variant="destructive">
<AlertCircle class="text-muted-foreground" />
<AlertTitle>{{ t('dialog.vrcx_updater.nightly_title') }}</AlertTitle>
<AlertDescription>
{{ t('dialog.vrcx_updater.nightly_notice') }}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<FieldGroup class="mt-3">
<Field>
<FieldLabel>{{ t('dialog.vrcx_updater.release') }}</FieldLabel>
<FieldContent>
<Select
:model-value="VRCXUpdateDialog.release"
@update:modelValue="(v) => (VRCXUpdateDialog.release = v)">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in VRCXUpdateDialog.releases"
:key="item.name"
:value="item.name">
{{ item.tag_name }}
</SelectItem>
</SelectContent>
</Select>
</FieldContent>
</Field>
</FieldGroup>
<div
v-if="!VRCXUpdateDialog.updatePending && VRCXUpdateDialog.release === appVersion"
class="mt-3 text-xs text-muted-foreground">
<span>{{ t('dialog.vrcx_updater.latest_version') }}</span>
</div>
</template>
</div>
<DialogFooter>
<Button variant="secondary" class="mr-2" v-if="updateInProgress" @click="cancelUpdate">
{{ t('dialog.vrcx_updater.cancel') }}
</Button>
<Button
variant="default"
v-if="VRCXUpdateDialog.release !== pendingVRCXInstall"
:disabled="updateInProgress"
@click="installVRCXUpdate">
{{ t('dialog.vrcx_updater.download') }}
</Button>
<Button variant="default" v-if="!updateInProgress && pendingVRCXInstall" @click="restartVRCX(true)">
{{ t('dialog.vrcx_updater.install') }}
</Button>
<Button variant="secondary" class="mr-2" v-if="updateInProgress" @click="cancelUpdate">
{{ t('dialog.vrcx_updater.cancel') }}
</Button>
<Button
variant="default"
v-if="VRCXUpdateDialog.release !== pendingVRCXInstall"
:disabled="updateInProgress"
@click="installVRCXUpdate">
{{ t('dialog.vrcx_updater.download') }}
</Button>
<Button variant="default" v-if="!updateInProgress && pendingVRCXInstall" @click="restartVRCX(true)">
{{ t('dialog.vrcx_updater.install') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Field, FieldContent, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AlertCircle } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';

View File

@@ -56,13 +56,13 @@
<AlertDialogPortal :to="portalTo">
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" />
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-11000 bg-black/80" />
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
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-11000 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
)
">

View File

@@ -51,7 +51,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class
)
">

View File

@@ -46,7 +46,7 @@
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 min-w-32 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class
)
">

View File

@@ -43,7 +43,7 @@
v-bind="{ ...$attrs, ...forwardedProps }"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 rounded-md border p-4 shadow-md outline-hidden',
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-64 rounded-md border p-4 shadow-md outline-hidden',
props.class
)
">

View File

@@ -52,7 +52,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class
)
">

View File

@@ -0,0 +1,44 @@
<script setup>
defineOptions({ inheritAttrs: false });
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'reka-ui';
import { ref, useAttrs } from 'vue';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import ScrollBar from './ScrollBar.vue';
const props = defineProps({
type: { type: null, required: false },
dir: { type: null, required: false },
scrollHideDelay: { type: Number, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const attrs = useAttrs();
const viewportEl = ref(null);
function setViewportEl(el) {
viewportEl.value = el?.$el ?? el ?? null;
}
defineExpose({ viewportEl, update: () => {} });
</script>
<template>
<ScrollAreaRoot data-slot="scroll-area" v-bind="delegatedProps" :class="cn('relative', props.class)">
<ScrollAreaViewport
data-slot="scroll-area-viewport"
:ref="setViewportEl"
v-bind="attrs"
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
v-bind="delegatedProps"
:class="
cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
props.class,
)
"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaScrollbar>
</template>

View File

@@ -0,0 +1,2 @@
export { default as ScrollArea } from "./ScrollArea.vue";
export { default as ScrollBar } from "./ScrollBar.vue";

View File

@@ -49,7 +49,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-12000 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class

View File

@@ -20,17 +20,20 @@
ariaLabel: { type: String, default: '' },
variant: { type: String, default: 'fit' },
unmountOnHide: { type: Boolean, default: false }
unmountOnHide: { type: Boolean, default: false },
fill: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
const { modelValue, defaultValue, items, ariaLabel, variant, unmountOnHide } = toRefs(props);
const { modelValue, defaultValue, items, ariaLabel, variant, unmountOnHide, fill } = toRefs(props);
const itemsList = computed(() => (Array.isArray(items.value) ? items.value : []));
const resolvedDefault = computed(() => {
return defaultValue.value ?? items.value?.[0]?.value;
return defaultValue.value ?? itemsList.value?.[0]?.value;
});
const isValueValid = (value) => items.value?.some((item) => item?.value === value);
const isValueValid = (value) => itemsList.value.some((item) => item?.value === value);
const innerValue = ref(isValueValid(modelValue.value) ? modelValue.value : resolvedDefault.value);
@@ -40,12 +43,13 @@
}
});
watch([items, defaultValue], () => {
watch([itemsList, defaultValue], () => {
if (!isValueValid(innerValue.value)) {
innerValue.value = resolvedDefault.value;
return;
}
if (!isValueValid(modelValue.value)) {
if (modelValue.value !== undefined && modelValue.value !== null && !isValueValid(modelValue.value)) {
innerValue.value = resolvedDefault.value;
}
});
@@ -79,7 +83,7 @@
<TabsRoot
:model-value="innerValue"
:default-value="resolvedDefault"
class="w-full"
:class="['w-full', fill ? 'flex min-h-0 flex-col' : '']"
:unmount-on-hide="unmountOnHide"
@update:modelValue="onValueChange">
<TabsList :class="listClass" :aria-label="ariaLabel || undefined">
@@ -89,7 +93,7 @@
</TabsIndicator>
<TabsTrigger
v-for="it in items"
v-for="it in itemsList"
:key="it.value"
:value="it.value"
:disabled="it.disabled"
@@ -99,10 +103,13 @@
</TabsList>
<TabsContent
v-for="it in items"
v-for="it in itemsList"
:key="it.value"
:value="it.value"
class="pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background">
:class="[
'pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background',
fill ? 'min-h-0 flex-1' : ''
]">
<slot :name="it.value" />
</TabsContent>
</TabsRoot>

View File

@@ -1,36 +1,36 @@
<script setup>
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
defineOptions({
inheritAttrs: false
});
defineOptions({
inheritAttrs: false
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
ariaLabel: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false, default: 4 },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
class: { type: null, required: false }
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
ariaLabel: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false, default: 4 },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
class: { type: null, required: false }
});
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']);
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
@@ -40,14 +40,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-12000 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class
)
">
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
class="bg-foreground fill-foreground z-12000 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -1,7 +1,7 @@
const MODAL_PORTAL_ROOT_ID = 'vrcx-modal-portal-root';
const APP_PORTAL_ROOT_ID = 'x-dialog-portal';
const BASE_Z_INDEX = 50;
const BASE_Z_INDEX = 10000;
const Z_STEP = 10;
let nextLayerIndex = 0;
@@ -15,11 +15,15 @@ function ensureModalPortalRoot() {
if (root) {
root.style.position ||= 'relative';
root.style.isolation ||= 'isolate';
root.style.zIndex ||= String(BASE_Z_INDEX);
return root;
}
root = document.getElementById(MODAL_PORTAL_ROOT_ID);
if (root) {
root.style.position ||= 'relative';
root.style.isolation ||= 'isolate';
root.style.zIndex ||= String(BASE_Z_INDEX);
return root;
}
@@ -27,6 +31,7 @@ function ensureModalPortalRoot() {
root.id = MODAL_PORTAL_ROOT_ID;
root.style.position = 'relative';
root.style.isolation = 'isolate';
root.style.zIndex = String(BASE_Z_INDEX);
document.body.appendChild(root);
return root;
}

View File

@@ -1,27 +1,3 @@
const elementPlusStrings = {
// Vite does not support dynamic imports to `node_modules`.
// https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations
cs: () => import('element-plus/es/locale/lang/cs'),
en: () => import('element-plus/es/locale/lang/en'),
es: () => import('element-plus/es/locale/lang/es'),
fr: () => import('element-plus/es/locale/lang/fr'),
hu: () => import('element-plus/es/locale/lang/hu'),
ja: () => import('element-plus/es/locale/lang/ja'),
ko: () => import('element-plus/es/locale/lang/ko'),
pl: () => import('element-plus/es/locale/lang/pl'),
pt: () => import('element-plus/es/locale/lang/pt'),
ru: () => import('element-plus/es/locale/lang/ru'),
th: () => import('element-plus/es/locale/lang/th'),
vi: () => import('element-plus/es/locale/lang/vi'),
'zh-CN': () => import('element-plus/es/locale/lang/zh-cn'),
'zh-TW': () => import('element-plus/es/locale/lang/zh-tw')
};
async function getElementPlusStrings(code) {
const loader = elementPlusStrings[code] || elementPlusStrings.en;
return (await loader().catch(() => elementPlusStrings.en())).default;
}
const localizedStringsUrls = import.meta.glob('./*.json', {
eager: true,
query: '?url',
@@ -50,10 +26,7 @@ async function getLocalizedStrings(code) {
}
}
return {
...localizedStrings,
elementPlus: await getElementPlusStrings(code)
};
return localizedStrings;
}
const languageNames = import.meta.glob('./*.json', {

View File

@@ -403,13 +403,15 @@ function openExternalLink(link) {
confirmText: 'Open',
cancelText: 'Copy'
})
// TODO: beforeClose alert dialog
.then(({ ok }) => {
if (!ok) {
.then(({ ok, reason }) => {
if (reason === 'cancel') {
copyToClipboard(link, 'Link copied to clipboard!');
return;
}
AppApi.OpenLink(link);
if (ok) {
AppApi.OpenLink(link);
return;
}
});
}

View File

@@ -8,7 +8,7 @@
<MutualFriends />
</template>
</TabsUnderline>
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
<BackToTop target="#chart" :right="30" :bottom="30" />
</div>
</template>
@@ -18,6 +18,8 @@
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import BackToTop from '@/components/BackToTop.vue';
import { useChartsStore } from '../../stores';
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));

View File

@@ -1,5 +1,6 @@
<template>
<div ref="instanceActivityRef" class="pt-12">
<BackToTop :target="instanceActivityRef" :right="30" :bottom="30" :teleport="false" />
<div class="options-container instance-activity" style="margin-top: 0">
<div>
<span>{{ t('view.charts.instance_activity.header') }}</span>
@@ -171,6 +172,7 @@
import { toDate } from 'reka-ui/date';
import { useI18n } from 'vue-i18n';
import BackToTop from '@/components/BackToTop.vue';
import dayjs from 'dayjs';
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover';
@@ -204,7 +206,7 @@
function setInstanceActivityHeight() {
if (instanceActivityRef.value) {
const availableHeight = window.innerHeight - 100;
const availableHeight = window.innerHeight - 110;
instanceActivityRef.value.style.height = `${availableHeight}px`;
instanceActivityRef.value.style.overflowY = 'auto';
}

View File

@@ -146,7 +146,7 @@
<DropdownMenuSubContent
side="right"
align="start"
class="w-[180px] p-1 rounded-lg">
class="w-45 p-1 rounded-lg">
<div class="group-visibility-menu">
<button
v-for="visibility in avatarGroupVisibilityOptions"
@@ -476,7 +476,7 @@
</div>
</template>
<template v-else-if="activeLocalGroupName">
<el-scrollbar
<ScrollArea
ref="localAvatarScrollbarRef"
class="favorites-content__scroll"
@scroll="handleLocalAvatarScroll">
@@ -495,7 +495,7 @@
</div>
</template>
<div v-else class="favorites-empty">No Data</div>
</el-scrollbar>
</ScrollArea>
</template>
<template v-else-if="isHistorySelected">
<div class="favorites-content__scroll favorites-content__scroll--native">
@@ -529,6 +529,7 @@
import { ArrowUpDown, Check, Ellipsis, Loader, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
@@ -1101,7 +1102,7 @@
if (!isLocalGroupSelected.value || isSearchActive.value) {
return;
}
const wrap = localAvatarScrollbarRef.value?.wrapRef;
const wrap = localAvatarScrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
@@ -1135,7 +1136,7 @@
if (!isLocalGroupSelected.value || isSearchActive.value) {
return;
}
const wrap = localAvatarScrollbarRef.value?.wrapRef;
const wrap = localAvatarScrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}

View File

@@ -404,7 +404,7 @@
</template>
<div v-else class="favorites-empty">No Data</div>
</div>
<el-scrollbar
<ScrollArea
v-else-if="activeLocalGroupName && isLocalGroupSelected"
ref="localFavoritesScrollbarRef"
class="favorites-content__scroll"
@@ -424,7 +424,7 @@
</div>
</template>
<div v-else class="favorites-empty">No Data</div>
</el-scrollbar>
</ScrollArea>
<div v-else class="favorites-empty">No Data</div>
</template>
</div>
@@ -440,6 +440,7 @@
import { ArrowUpDown, Ellipsis, MoreHorizontal, Plus, RefreshCcw, RefreshCw } from 'lucide-vue-next';
import { InputGroupField, InputGroupSearch } from '@/components/ui/input-group';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
@@ -1004,7 +1005,7 @@
if (!isLocalGroupSelected.value || isSearchActive.value) {
return;
}
const wrap = localFavoritesScrollbarRef.value?.wrapRef;
const wrap = localFavoritesScrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
@@ -1097,7 +1098,7 @@
if (!isLocalGroupSelected.value || isSearchActive.value) {
return;
}
const wrap = localFavoritesScrollbarRef.value?.wrapRef;
const wrap = localFavoritesScrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}

View File

@@ -25,8 +25,8 @@
:rows="10"
style="margin-top: 10px"
input-class="resize-none" />
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 5px">
<div>
<div>
<div class="mb-2">
<div class="flex items-center gap-2">
<Select
:model-value="worldImportFavoriteGroupSelection"

View File

@@ -306,6 +306,7 @@ export const columns = [
location={original.location}
hint={original.worldName}
grouphint={original.groupName}
disableTooltip
/>
</div>
) : null;
@@ -318,6 +319,7 @@ export const columns = [
location={original.location}
hint={original.worldName}
grouphint={original.groupName}
disableTooltip
/>
</div>
) : null;
@@ -350,7 +352,7 @@ export const columns = [
}
return (
<span class="block w-full min-w-0 truncate">
<div class="block w-full min-w-0 truncate">
<i
class={[
'x-user-status',
@@ -359,7 +361,7 @@ export const columns = [
]}
></i>
<span>{original.statusDescription}</span>
</span>
</div>
);
}
@@ -379,13 +381,9 @@ export const columns = [
if (type === 'Bio') {
return (
<span
class="block w-full min-w-0 truncate"
innerHTML={formatDifference(
original.previousBio,
original.bio
)}
></span>
<div class="block w-full min-w-0 truncate">
{original.bio}
</div>
);
}

View File

@@ -58,7 +58,7 @@
<div v-else class="friend-view__toolbar friend-view__toolbar--loading">
<span class="friend-view__loading-text">{{ t('view.friends_locations.loading_more') }}</span>
</div>
<el-scrollbar v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
<ScrollArea v-if="settingsReady" ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
<template v-if="isSameInstanceView">
<div v-if="visibleSameInstanceGroups.length" class="friend-view__instances">
<section
@@ -151,7 +151,7 @@
<Loader2 class="friend-view__loading-icon" :size="18" />
<span>{{ t('view.friends_locations.loading_more') }}</span>
</div>
</el-scrollbar>
</ScrollArea>
<div v-else class="friend-view__initial-loading">
<Loader2 class="friend-view__loading-icon" :size="22" />
</div>
@@ -164,6 +164,7 @@
import { Loader2, Settings } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { InputGroupSearch } from '@/components/ui/input-group';
import { ScrollArea } from '@/components/ui/scroll-area';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -262,7 +263,7 @@
let cleanupResize;
const updateGridWidth = () => {
const wrap = scrollbarRef.value?.wrapRef;
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
@@ -276,7 +277,7 @@
cleanupResize = undefined;
}
const wrap = scrollbarRef.value?.wrapRef;
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
@@ -559,7 +560,7 @@
return;
}
const wrap = scrollbarRef.value?.wrapRef;
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
@@ -590,7 +591,7 @@
function maybeFillViewport() {
nextTick(() => {
const wrap = scrollbarRef.value?.wrapRef;
const wrap = scrollbarRef.value?.viewportEl?.value;
if (!wrap) {
return;
}
@@ -634,7 +635,6 @@
return;
}
nextTick(() => {
scrollbarRef.value?.update?.();
updateGridWidth();
maybeFillViewport();
});
@@ -697,7 +697,6 @@
settingsReady.value = true;
nextTick(() => {
setupResizeHandling();
scrollbarRef.value?.update?.();
updateGridWidth();
maybeFillViewport();
});

View File

@@ -352,66 +352,52 @@
@change="updateTrustColor('', '', true)"></simple-switch>
<div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.untrusted"
size="small"
:predefine="['#CCCCCC']"
@change="updateTrustColor('untrusted', $event)">
</el-color-picker>
:presets="['#CCCCCC']"
@change="updateTrustColor('untrusted', $event)" />
<span class="color-picker x-tag-untrusted">Visitor</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.basic"
size="small"
:predefine="['#1778ff']"
@change="updateTrustColor('basic', $event)">
</el-color-picker>
:presets="['#1778ff']"
@change="updateTrustColor('basic', $event)" />
<span class="color-picker x-tag-basic">New User</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.known"
size="small"
:predefine="['#2bcf5c']"
@change="updateTrustColor('known', $event)">
</el-color-picker>
:presets="['#2bcf5c']"
@change="updateTrustColor('known', $event)" />
<span class="color-picker x-tag-known">User</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.trusted"
size="small"
:predefine="['#ff7b42']"
@change="updateTrustColor('trusted', $event)">
</el-color-picker>
:presets="['#ff7b42']"
@change="updateTrustColor('trusted', $event)" />
<span class="color-picker x-tag-trusted">Known User</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.veteran"
size="small"
:predefine="['#b18fff', '#8143e6', '#ff69b4', '#b52626', '#ffd000', '#abcdef']"
@change="updateTrustColor('veteran', $event)">
</el-color-picker>
:presets="['#b18fff', '#8143e6', '#ff69b4', '#b52626', '#ffd000', '#abcdef']"
@change="updateTrustColor('veteran', $event)" />
<span class="color-picker x-tag-veteran">Trusted User</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.vip"
size="small"
:predefine="['#ff2626']"
@change="updateTrustColor('vip', $event)">
</el-color-picker>
:presets="['#ff2626']"
@change="updateTrustColor('vip', $event)" />
<span class="color-picker x-tag-vip">VRChat Team</span>
</div>
<div>
<el-color-picker
<PresetColorPicker
:model-value="trustColor.troll"
size="small"
:predefine="['#782f2f']"
@change="updateTrustColor('troll', $event)">
</el-color-picker>
:presets="['#782f2f']"
@change="updateTrustColor('troll', $event)" />
<span class="color-picker x-tag-troll">Nuisance</span>
</div>
</div>
@@ -445,6 +431,8 @@
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import PresetColorPicker from '@/components/PresetColorPicker.vue';
import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '../../../../stores';
import { getLanguageName, languageCodes } from '../../../../localization';
import { THEME_CONFIG } from '../../../../shared/constants';

View File

@@ -4,10 +4,7 @@
<div style="flex: 1; padding: 10px; padding-left: 0">
<Popover v-model:open="isQuickSearchOpen">
<PopoverTrigger as-child>
<Input
v-model="quickSearchQuery"
:placeholder="t('side_panel.search_placeholder')"
@focus="handleQuickSearchFocus" />
<Input v-model="quickSearchQuery" :placeholder="t('side_panel.search_placeholder')" />
</PopoverTrigger>
<PopoverContent
side="bottom"
@@ -78,6 +75,7 @@
:items="sidebarTabs"
:unmount-on-hide="false"
variant="equal"
fill
class="zero-margin-tabs"
style="height: calc(100% - 70px); margin-top: 5px">
<template #label-friends>
@@ -89,14 +87,19 @@
<span class="sidebar-tab-count"> ({{ groupInstances.length }}) </span>
</template>
<template #friends>
<div class="el-tabs__content">
<el-backtop target=".zero-margin-tabs .el-tabs__content" :bottom="20" :right="20"></el-backtop>
<FriendsSidebar @confirm-delete-friend="confirmDeleteFriend" />
<div class="h-full overflow-hidden">
<ScrollArea ref="friendsScrollAreaRef" class="h-full">
<FriendsSidebar @confirm-delete-friend="confirmDeleteFriend" />
</ScrollArea>
<BackToTop :target="friendsScrollTarget" :bottom="20" :right="20" :teleport="false" />
</div>
</template>
<template #groups>
<div class="el-tabs__content">
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
<div class="h-full overflow-hidden">
<ScrollArea ref="groupsScrollAreaRef" class="h-full">
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
</ScrollArea>
<BackToTop :target="groupsScrollTarget" :bottom="20" :right="20" :teleport="false" />
</div>
</template>
</TabsUnderline>
@@ -104,16 +107,19 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-vue-next';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Spinner } from '@/components/ui/spinner';
import { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import BackToTop from '@/components/BackToTop.vue';
import { useFriendStore, useGroupStore, useSearchStore } from '../../stores';
import { userImage } from '../../shared/utils';
@@ -134,6 +140,25 @@
const quickSearchQuery = ref('');
const isQuickSearchOpen = ref(false);
const friendsScrollAreaRef = ref(null);
const groupsScrollAreaRef = ref(null);
const friendsScrollTarget = ref(null);
const groupsScrollTarget = ref(null);
function resolveScrollViewport(scrollAreaComponentRef) {
// Our ScrollArea renders a DOM element root; the viewport is marked by data-slot.
const rootEl = scrollAreaComponentRef?.$el ?? null;
if (!rootEl || typeof rootEl.querySelector !== 'function') return null;
return rootEl.querySelector('[data-slot="scroll-area-viewport"]');
}
onMounted(async () => {
// Ensure child components are mounted before querying their DOM.
await nextTick();
friendsScrollTarget.value = resolveScrollViewport(friendsScrollAreaRef.value);
groupsScrollTarget.value = resolveScrollViewport(groupsScrollAreaRef.value);
});
watch(
quickSearchQuery,
(value) => {
@@ -142,11 +167,6 @@
{ immediate: true }
);
function handleQuickSearchFocus() {
isQuickSearchOpen.value = true;
quickSearchRemoteMethod(String(quickSearchQuery.value ?? ''));
}
function handleQuickSearchSelect(value) {
if (!value) {
return;

View File

@@ -45,7 +45,7 @@
</Button>
</template>
<div v-else class="skeleton" aria-busy="true" aria-label="Loading">
<!-- <div v-else class="skeleton" aria-busy="true" aria-label="Loading">
<div>
<Skeleton class="h-10 w-10 rounded-full" />
<div>
@@ -53,7 +53,7 @@
<Skeleton class="mt-1.5 h-3 w-full" />
</div>
</div>
</div>
</div> -->
</div>
</template>

View File

@@ -42,11 +42,7 @@
const timeZone = getLocalTimeZone();
// JSDoc casts: this project can end up with nominal-type mismatches for DateValue
// due to duplicate @internationalized/date copies in tooling.
/** @type {import('vue').Ref<any>} */
const internalValue = ref(fromDate(props.modelValue ?? new Date(), timeZone));
/** @type {import('vue').Ref<any>} */
const placeholder = ref(fromDate(props.modelValue ?? new Date(), timeZone));
watch(
@@ -147,6 +143,10 @@
:class="hasFollowingFor(weekDate) ? 'has-following' : 'no-following'">
{{ eventCountFor(weekDate) }}
</div>
<!-- <div
v-if="eventCountFor(weekDate) > 0"
class="calendar-event-dot"
aria-hidden="true" /> -->
</div>
</div>
</CalendarCellTrigger>
@@ -165,7 +165,6 @@
width: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
}
.date {
@@ -193,21 +192,20 @@
.calendar-event-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
top: -4px;
right: -4px;
min-width: 14px;
height: 14px;
border-radius: 9px;
color: var(--el-color-white, #fff);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
font-size: 10px;
box-shadow: var(--el-box-shadow-lighter);
z-index: 10;
padding: 0 5px;
line-height: 18px;
line-height: 14px;
}
.calendar-event-badge.has-following {
@@ -217,4 +215,18 @@
.calendar-event-badge.no-following {
background-color: var(--group-calendar-badge-normal, var(--el-color-primary));
}
.calendar-event-dot {
position: absolute;
left: 50%;
bottom: 4px;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 9999px;
background-color: var(--group-calendar-event-dot, #ef4444);
box-shadow: var(--el-box-shadow-lighter);
z-index: 5;
pointer-events: none;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<Dialog :open="visible" @update:open="(open) => (open ? null : closeDialog())">
<DialogContent class="x-dialog w-[90vw] max-w-[90vw] sm:max-w-[70vw] h-[60vh] overflow-hidden">
<DialogContent class="x-dialog sm:max-w-[50vw] h-[60vh] overflow-hidden">
<DialogHeader>
<div class="dialog-title-container">
<DialogTitle>{{ t('dialog.group_calendar.header') }}</DialogTitle>
@@ -18,82 +18,77 @@
</div>
</DialogHeader>
<div class="top-content">
<transition name="el-fade-in-linear" mode="out-in">
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
<div class="timeline-container">
<el-timeline v-if="groupedTimelineEvents.length">
<el-timeline-item
v-for="(timeGroup, key) of groupedTimelineEvents"
:key="key"
:timestamp="
dayjs(timeGroup.startsAt).format('MM-DD ddd') + ' ' + timeGroup.startTime
"
placement="top">
<div class="time-group-container">
<GroupCalendarEventCard
v-for="value in timeGroup.events"
:key="value.id"
:event="value"
mode="timeline"
:is-following="isEventFollowing(value.id)"
:card-class="{ 'grouped-card': timeGroup.events.length > 1 }"
@update-following-calendar-data="updateFollowingCalendarData"
@click-action="showGroupDialog(value.ownerId)" />
</div>
</el-timeline-item>
</el-timeline>
<div v-else>{{ t('dialog.group_calendar.no_events') }}</div>
</div>
<div class="calendar-container">
<GroupCalendarMonth
v-model="selectedDay"
:is-loading="isLoading"
:events-by-date="filteredCalendar"
:following-by-date="followingCalendarDate" />
</div>
</div>
<div v-else key="grid" class="grid-view">
<div class="search-container">
<InputGroupSearch
v-model="searchQuery"
size="sm"
:placeholder="t('dialog.group_calendar.search_placeholder')"
class="search-input" />
</div>
<div class="groups-grid" v-loading="isLoading">
<div v-if="filteredGroupEvents.length" class="groups-container">
<div v-for="group in filteredGroupEvents" :key="group.groupId" class="group-row">
<div class="group-header" @click="toggleGroup(group.groupId)">
<ArrowRight
class="rotation-transition"
:class="{ rotate: !groupCollapsed[group.groupId] }" />
{{ group.groupName }}
</div>
<div class="events-row" v-show="!groupCollapsed[group.groupId]">
<GroupCalendarEventCard
v-for="event in group.events"
:key="event.id"
:event="event"
mode="grid"
:is-following="isEventFollowing(event.id)"
@update-following-calendar-data="updateFollowingCalendarData"
@click-action="showGroupDialog(event.ownerId)"
card-class="grid-card" />
</div>
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
<div class="timeline-container">
<div v-if="groupedTimelineEvents.length" class="timeline-list">
<div v-for="(timeGroup, key) of groupedTimelineEvents" :key="key" class="timeline-group">
<div class="timeline-timestamp">
{{ dayjs(timeGroup.startsAt).format('MM-DD ddd') }} {{ timeGroup.startTime }}
</div>
<div class="time-group-container">
<GroupCalendarEventCard
v-for="value in timeGroup.events"
:key="value.id"
:event="value"
mode="timeline"
:is-following="isEventFollowing(value.id)"
:card-class="{ 'grouped-card': timeGroup.events.length > 1 }"
@update-following-calendar-data="updateFollowingCalendarData"
@click-action="showGroupDialog(value.ownerId)" />
</div>
</div>
<div v-else class="no-events">
{{
searchQuery
? t('dialog.group_calendar.search_no_matching')
: t('dialog.group_calendar.search_no_this_month')
}}
</div>
<div v-else class="timeline-empty">{{ t('dialog.group_calendar.no_events') }}</div>
</div>
<div class="calendar-container">
<GroupCalendarMonth
v-model="selectedDay"
:is-loading="isLoading"
:events-by-date="filteredCalendar"
:following-by-date="followingCalendarDate" />
</div>
</div>
<div v-else key="grid" class="grid-view">
<div class="search-container">
<InputGroupSearch
v-model="searchQuery"
size="sm"
:placeholder="t('dialog.group_calendar.search_placeholder')"
class="search-input" />
</div>
<div class="groups-grid" v-loading="isLoading">
<div v-if="filteredGroupEvents.length" class="groups-container">
<div v-for="group in filteredGroupEvents" :key="group.groupId" class="group-row">
<div class="group-header" @click="toggleGroup(group.groupId)">
<ArrowRight
class="rotation-transition"
:class="{ rotate: !groupCollapsed[group.groupId] }" />
{{ group.groupName }}
</div>
<div class="events-row" v-show="!groupCollapsed[group.groupId]">
<GroupCalendarEventCard
v-for="event in group.events"
:key="event.id"
:event="event"
mode="grid"
:is-following="isEventFollowing(event.id)"
@update-following-calendar-data="updateFollowingCalendarData"
@click-action="showGroupDialog(event.ownerId)"
card-class="grid-card" />
</div>
</div>
</div>
<div v-else class="no-events">
{{
searchQuery
? t('dialog.group_calendar.search_no_matching')
: t('dialog.group_calendar.search_no_this_month')
}}
</div>
</div>
</transition>
</div>
</div>
</DialogContent>
</Dialog>
@@ -334,7 +329,8 @@
.sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt)));
});
const formatDateKey = (date) => formatDateFilter(date, 'date');
// Use a stable key for calendar maps (independent of locale/appearance date formatting).
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD');
function getGroupNameFromCache(groupId) {
if (!groupNamesCache.has(groupId)) {
@@ -462,18 +458,36 @@
overflow: hidden;
.timeline-view {
.timeline-container {
:deep(.el-timeline) {
width: 100%;
min-width: 200px;
padding-left: 4px;
padding-right: 16px;
margin-left: 10px;
margin-right: 6px;
overflow: auto;
.timeline-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.timeline-group {
padding: 0 20px 6px 10px;
}
.timeline-timestamp {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.timeline-empty {
height: 100%;
min-width: 200px;
padding-left: 4px;
padding-right: 16px;
margin-left: 10px;
margin-right: 6px;
overflow: auto;
.el-timeline-item {
padding: 0 20px 20px 10px;
}
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.time-group-container {
display: flex;
@@ -571,7 +585,6 @@
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.calendar-container {
width: 609px;