mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-01 12:43:46 +02:00
replace element plus components
This commit is contained in:
177
src/components/BackToTop.vue
Normal file
177
src/components/BackToTop.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
100
src/components/PresetColorPicker.vue
Normal file
100
src/components/PresetColorPicker.vue
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
">
|
||||
|
||||
44
src/components/ui/scroll-area/ScrollArea.vue
Normal file
44
src/components/ui/scroll-area/ScrollArea.vue
Normal 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>
|
||||
37
src/components/ui/scroll-area/ScrollBar.vue
Normal file
37
src/components/ui/scroll-area/ScrollBar.vue
Normal 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>
|
||||
2
src/components/ui/scroll-area/index.js
Normal file
2
src/components/ui/scroll-area/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from "./ScrollArea.vue";
|
||||
export { default as ScrollBar } from "./ScrollBar.vue";
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user