Files
VRCX/src/components/dialogs/WorldDialog/WorldDialog.vue
2026-03-08 23:59:04 +09:00

1592 lines
76 KiB
Vue

<template>
<div class="w-223">
<DialogHeader class="sr-only">
<DialogTitle>{{ worldDialog.ref?.name || t('dialog.world.info.header') }}</DialogTitle>
<DialogDescription>
{{ worldDialog.ref?.description || worldDialog.ref?.name || t('dialog.world.info.header') }}
</DialogDescription>
</DialogHeader>
<div>
<div style="display: flex">
<div style="flex: none; width: 160px; height: 120px">
<img
v-if="!worldDialog.loading"
:src="worldDialog.ref.thumbnailImageUrl"
class="cursor-pointer"
style="width: 160px; height: 120px; border-radius: 12px"
@click="showFullscreenImageDialog(worldDialog.ref.imageUrl)"
loading="lazy" />
</div>
<div style="flex: 1; display: flex; align-items: flex-start; margin-left: 15px">
<div style="flex: 1">
<div>
<span class="font-bold" style="margin-right: 5px; cursor: pointer" @click="copyWorldName">
<Home
v-if="
currentUser.$homeLocation &&
currentUser.$homeLocation.worldId === worldDialog.id
"
class="inline-block" />
{{ worldDialog.ref.name }}
</span>
</div>
<div style="margin-top: 5px">
<span
class="cursor-pointer x-grey"
style="font-family: monospace"
@click="showUserDialog(worldDialog.ref.authorId)"
v-text="worldDialog.ref.authorName" />
</div>
<div>
<Badge
v-if="worldDialog.ref.$isLabs"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.labs') }}
</Badge>
<Badge
v-else-if="worldDialog.ref.releaseStatus === 'public'"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.public') }}
</Badge>
<Badge v-else variant="outline" style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.private') }}
</Badge>
<TooltipWrapper v-if="worldDialog.isPC" side="top" content="PC">
<Badge
class="x-tag-platform-pc"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
<Monitor class="h-4 w-4 x-tag-platform-pc" />
<span
v-if="worldDialog.fileAnalysis.standalonewindows?._fileSize"
:class="['x-grey', 'x-tag-platform-pc', 'x-tag-border-left']">
{{ worldDialog.fileAnalysis.standalonewindows._fileSize }}
</span>
</Badge>
</TooltipWrapper>
<TooltipWrapper v-if="worldDialog.isQuest" side="top" content="Quest">
<Badge
class="x-tag-platform-quest"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
<Smartphone class="h-4 w-4 x-tag-platform-quest" />
<span
v-if="worldDialog.fileAnalysis.android?._fileSize"
:class="['x-grey', 'x-tag-platform-quest', 'x-tag-border-left']">
{{ worldDialog.fileAnalysis.android._fileSize }}
</span>
</Badge>
</TooltipWrapper>
<TooltipWrapper v-if="worldDialog.isIos" side="top" content="iOS">
<Badge
class="text-platform-ios border-platform-ios"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
<Apple class="h-4 w-4 text-platform-ios" />
<span
v-if="worldDialog.fileAnalysis.ios?._fileSize"
:class="[
'x-grey',
'x-tag-border-left',
'text-platform-ios',
'border-platform-ios'
]">
{{ worldDialog.fileAnalysis.ios._fileSize }}
</span>
</Badge>
</TooltipWrapper>
<Badge
v-if="worldDialog.avatarScalingDisabled"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.avatar_scaling_disabled') }}
</Badge>
<Badge
v-if="worldDialog.focusViewDisabled"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.focus_view_disabled') }}
</Badge>
<Badge
v-if="worldDialog.ref.unityPackageUrl"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
{{ t('dialog.world.tags.future_proofing') }}
</Badge>
<Badge
v-if="worldDialog.inCache"
variant="outline"
class="cursor-pointer"
style="margin-right: 5px; margin-top: 5px"
@click="openFolderGeneric(worldDialog.cachePath)">
<span v-text="worldDialog.cacheSize" />
| {{ t('dialog.world.tags.cache') }}
</Badge>
</div>
<div>
<template v-for="tag in worldDialog.ref.tags" :key="tag">
<Badge
v-if="tag.startsWith('content_')"
variant="outline"
style="margin-right: 5px; margin-top: 5px">
<span v-if="tag === 'content_horror'">
{{ t('dialog.world.tags.content_horror') }}
</span>
<span v-else-if="tag === 'content_gore'">
{{ t('dialog.world.tags.content_gore') }}
</span>
<span v-else-if="tag === 'content_violence'">
{{ t('dialog.world.tags.content_violence') }}
</span>
<span v-else-if="tag === 'content_adult'">
{{ t('dialog.world.tags.content_adult') }}
</span>
<span v-else-if="tag === 'content_sex'">
{{ t('dialog.world.tags.content_sex') }}
</span>
<span v-else>
{{ tag.replace('content_', '') }}
</span>
</Badge>
</template>
</div>
<div style="margin-top: 5px; display: flex; align-items: center">
<span
v-show="worldDialog.ref.name !== worldDialog.ref.description"
style="font-size: 12px; flex: 1; margin-right: 0.5em"
>{{ translatedDescription || worldDialog.ref.description }}</span
>
<Button
v-if="
translationApi &&
worldDialog.ref.description &&
worldDialog.ref.name !== worldDialog.ref.description
"
class="w-3 h-6 text-xs"
size="icon-sm"
variant="ghost"
@click="translateDescription">
<Spinner v-if="isTranslating" class="size-1" />
<Languages v-else class="h-3 w-3" />
</Button>
</div>
</div>
<div class="ml-2 mt-12">
<TooltipWrapper
v-if="worldDialog.inCache"
side="top"
:content="t('dialog.world.actions.delete_cache_tooltip')">
<Button
class="rounded-full mr-2"
size="icon-lg"
variant="outline"
:disabled="isGameRunning && worldDialog.cacheLocked"
@click="deleteVRChatCache(worldDialog.ref)"
><Trash2
/></Button>
</TooltipWrapper>
<TooltipWrapper
v-if="worldDialog.isFavorite"
side="top"
:content="t('dialog.world.actions.favorites_tooltip')">
<Button class="rounded-full" size="icon-lg" @click="worldDialogCommand('Add Favorite')"
><Star
/></Button>
</TooltipWrapper>
<TooltipWrapper v-else side="top" :content="t('dialog.world.actions.favorites_tooltip')">
<Button
class="rounded-full"
size="icon-lg"
variant="outline"
@click="worldDialogCommand('Add Favorite')"
><Star
/></Button>
</TooltipWrapper>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon-lg" class="rounded-full ml-2">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="worldDialogCommand('Refresh')">
<RefreshCw class="size-4" />
{{ t('dialog.world.actions.refresh') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Share')">
<Share2 class="size-4" />
{{ t('dialog.world.actions.share') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="worldDialogCommand('New Instance')">
<Flag class="size-4" />
{{ t('dialog.world.actions.new_instance') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('New Instance and Self Invite')">
<MessageSquare class="size-4" />
{{
canOpenInstanceInGame
? t('dialog.world.actions.new_instance_and_open_ingame')
: t('dialog.world.actions.new_instance_and_self_invite')
}}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="
currentUser.$homeLocation &&
currentUser.$homeLocation.worldId === worldDialog.id
"
@click="worldDialogCommand('Reset Home')">
<Wand2 class="size-4" />
{{ t('dialog.world.actions.reset_home') }}
</DropdownMenuItem>
<DropdownMenuItem v-else @click="worldDialogCommand('Make Home')">
<Home class="size-4" />
{{ t('dialog.world.actions.make_home') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Previous Instances')">
<LineChart class="size-4" />
{{ t('dialog.world.actions.show_previous_instances') }}
</DropdownMenuItem>
<template v-if="currentUser.id !== worldDialog.ref.authorId">
<DropdownMenuItem
:disabled="!worldDialog.hasPersistData"
@click="worldDialogCommand('Delete Persistent Data')">
<Upload class="size-4" />
{{ t('dialog.world.actions.delete_persistent_data') }}
</DropdownMenuItem>
</template>
<template v-else>
<DropdownMenuItem @click="worldDialogCommand('Rename')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.rename') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change Description')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_description') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change Capacity')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_capacity') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change Recommended Capacity')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_recommended_capacity') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change YouTube Preview')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_preview') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change Tags')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_warnings_settings_tags') }}
</DropdownMenuItem>
<DropdownMenuItem @click="worldDialogCommand('Change Allowed Domains')">
<Pencil class="size-4" />
{{ t('dialog.world.actions.change_allowed_video_player_domains') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="isWindows" @click="worldDialogCommand('Change Image')">
<Image class="size-4" />
{{ t('dialog.world.actions.change_image') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="worldDialog.ref.unityPackageUrl"
@click="worldDialogCommand('Download Unity Package')">
<Download class="size-4" />
{{ t('dialog.world.actions.download_package') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="
worldDialog.ref?.tags?.includes('system_approved') ||
worldDialog.ref?.tags?.includes('system_labs')
"
@click="worldDialogCommand('Unpublish')">
<Eye class="size-4" />
{{ t('dialog.world.actions.unpublish') }}
</DropdownMenuItem>
<DropdownMenuItem v-else @click="worldDialogCommand('Publish')">
<Eye class="size-4" />
{{ t('dialog.world.actions.publish_to_labs') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!worldDialog.hasPersistData"
@click="worldDialogCommand('Delete Persistent Data')">
<Upload class="size-4" />
{{ t('dialog.world.actions.delete_persistent_data') }}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" @click="worldDialogCommand('Delete')">
<Trash2 class="size-4" />
{{ t('dialog.world.actions.delete') }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<TabsUnderline
v-model="worldDialog.activeTab"
:items="worldDialogTabs"
:unmount-on-hide="false"
@update:modelValue="worldDialogTabClick">
<template #Instances>
<div class="flex items-center text-sm">
<User />
{{ t('dialog.world.instances.public_count', { count: worldDialog.ref.publicOccupants }) }}
<User style="margin-left: 10px" />
{{
t('dialog.world.instances.private_count', {
count: worldDialog.ref.privateOccupants
})
}}
<Check style="margin-left: 10px" />
{{
t('dialog.world.instances.capacity_count', {
count: worldDialog.ref.recommendedCapacity,
max: worldDialog.ref.capacity
})
}}
</div>
<div v-for="room in worldDialog.rooms" :key="room.id">
<template
v-if="isAgeGatedInstancesVisible || !(room.ageGate || room.location?.includes('~ageGate'))">
<div style="margin: 5px 0">
<div class="flex items-center">
<LocationWorld
class="text-sm"
:locationobject="room.$location"
:currentuserid="currentUser.id"
:worlddialogshortname="worldDialog.$location.shortName" />
<InstanceActionBar
class="ml-1 text-sm"
:location="room.$location.tag"
:launch-location="room.tag"
:instance-location="room.tag"
:shortname="room.$location.shortName"
:currentlocation="lastLocation.location"
:instance="room.ref"
:friendcount="room.friendCount"
:refresh-tooltip="t('dialog.world.instances.refresh_instance_info')"
:show-history="!!instanceJoinHistory.get(room.$location.tag)"
:history-tooltip="t('dialog.previous_instances.info')"
:on-refresh="() => refreshInstancePlayerCount(room.tag)"
:on-history="() => showPreviousInstancesInfoDialog(room.location)" />
</div>
<div
v-if="room.$location.userId || room.users.length"
class="flex flex-wrap items-start"
style="margin: 10px 0; max-height: unset">
<div
v-if="room.$location.userId"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
@click="showUserDialog(room.$location.userId)">
<template v-if="room.$location.user">
<div
class="relative inline-block flex-none size-9 mr-2.5"
:class="userStatusClass(room.$location.user)">
<img
class="size-full rounded-full object-cover"
:src="userImage(room.$location.user, true)"
loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: room.$location.user.$userColour }"
v-text="room.$location.user.displayName" />
<span class="block truncate text-xs">
{{ t('dialog.world.instances.instance_creator') }}
</span>
</div>
</template>
<span v-else v-text="room.$location.userId" />
</div>
<div
v-for="user in room.users"
:key="user.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
@click="showUserDialog(user.id)">
<div
class="relative inline-block flex-none size-9 mr-2.5"
:class="userStatusClass(user)">
<img
class="size-full rounded-full object-cover"
:src="userImage(user, true)"
loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: user.$userColour }"
v-text="user.displayName" />
<span v-if="user.location === 'traveling'" class="block truncate text-xs">
<Spinner class="inline-block mr-1" />
<Timer :epoch="user.$travelingToTime" />
</span>
<span v-else class="block truncate text-xs">
<Timer :epoch="user.$location_at" />
</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<template #Info>
<div class="flex flex-wrap items-start px-2.5" style="max-height: none">
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.memo') }}
</span>
<InputGroupTextareaField
v-model="memo"
class="text-xs"
:rows="2"
:placeholder="t('dialog.world.info.memo_placeholder')"
input-class="resize-none min-h-0"
@change="onWorldMemoChange" />
</div>
</div>
<div style="width: 100%; display: flex">
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.id') }}
</span>
<span class="block truncate text-xs" style="display: inline">
{{ worldDialog.id }}
</span>
<TooltipWrapper side="top" :content="t('dialog.world.info.id_tooltip')">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
class="rounded-full text-xs"
size="icon-sm"
variant="ghost"
@click.stop
><Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="copyWorldId()">
{{ t('dialog.world.info.copy_id') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyWorldUrl()">
{{ t('dialog.world.info.copy_url') }}
</DropdownMenuItem>
<DropdownMenuItem @click="copyWorldName()">
{{ t('dialog.world.info.copy_name') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipWrapper>
</div>
</div>
</div>
<div
v-if="worldDialog.ref.previewYoutubeId"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer"
style="width: 350px"
@click="
openExternalLink(`https://www.youtube.com/watch?v=${worldDialog.ref.previewYoutubeId}`)
">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.youtube_preview') }}
</span>
<span class="block truncate text-xs">
https://www.youtube.com/watch?v={{ worldDialog.ref.previewYoutubeId }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.author_tags') }}
</span>
<span
v-if="
worldDialog.ref.tags?.filter((tag) => tag.startsWith('author_tag')).length > 0
"
class="block truncate text-xs">
{{ worldTags }}
</span>
<span v-else class="block truncate text-xs"> - </span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.players') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.occupants) }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.favorites') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.favorites)
}}<span
v-if="worldDialog.ref?.favorites > 0 && worldDialog.ref?.visits > 0"
class="text-xs">
({{ favoriteRate }}%)
</span>
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.visits') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.visits) }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.capacity') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.recommendedCapacity) }} ({{
commaNumber(worldDialog.ref.capacity)
}})
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.created_at') }}
</span>
<span class="block truncate text-xs">
{{ formatDateFilter(worldDialog.ref.created_at, 'long') }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]" style="display: inline">
{{ t('dialog.world.info.last_updated') }}
</span>
<TooltipWrapper
v-if="Object.keys(worldDialog.fileAnalysis).length"
side="top"
style="margin-left: 5px">
<template #content>
<template
v-for="(created_at, platform) in worldDialogPlatformCreatedAt"
:key="platform">
<div class="flex justify-between w-full">
<span class="mr-1">{{ platform }}:</span>
<span>{{ formatDateFilter(created_at, 'long') }}</span>
</div>
</template>
</template>
<ChevronDown class="inline-block" />
</TooltipWrapper>
<span class="block truncate text-xs">
{{ formatDateFilter(worldDialog.ref.updated_at, 'long') }}
</span>
</div>
</div>
<div
v-if="worldDialog.ref.labsPublicationDate !== 'none'"
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.labs_publication_date') }}
</span>
<span class="block truncate text-xs">
{{ formatDateFilter(worldDialog.ref.labsPublicationDate, 'long') }}
</span>
</div>
</div>
<div
v-if="worldDialog.ref.publicationDate !== 'none'"
class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]" style="display: inline">
{{ t('dialog.world.info.publication_date') }}
</span>
<TooltipWrapper v-if="isTimeInLabVisible" side="top" style="margin-left: 5px">
<template #content>
<span>
{{ t('dialog.world.info.time_in_labs') }}
{{ timeInLab }}
</span>
</template>
<ChevronDown class="inline-block" />
</TooltipWrapper>
<span class="block truncate text-xs">
{{ formatDateFilter(worldDialog.ref.publicationDate, 'long') }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.version') }}
</span>
<span class="block truncate text-xs" v-text="worldDialog.ref.version" />
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.heat') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.heat) }} {{ '🔥'.repeat(worldDialog.ref.heat) }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.popularity') }}
</span>
<span class="block truncate text-xs">
{{ commaNumber(worldDialog.ref.popularity) }}
{{ '💖'.repeat(worldDialog.ref.popularity) }}
</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.platform') }}
</span>
<span class="block truncate text-xs" style="white-space: normal">{{
worldDialogPlatform
}}</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.last_visited') }}
</span>
<span class="block truncate text-xs">{{
formatDateFilter(worldDialog.lastVisit, 'long')
}}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(worldDialog.ref)">
<div class="flex-1 overflow-hidden">
<div
class="block truncate font-medium leading-[18px]"
style="display: flex; justify-content: space-between; align-items: center">
<div>
{{ t('dialog.world.info.visit_count') }}
</div>
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
<MoreHorizontal style="margin-right: 16px" />
</TooltipWrapper>
</div>
<span v-if="worldDialog.visitCount === 0" class="block truncate text-xs">-</span>
<span v-else class="block truncate text-xs" v-text="worldDialog.visitCount"></span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">
{{ t('dialog.world.info.time_spent') }}
</span>
<span class="block truncate text-xs">
{{ worldDialog.timeSpent === 0 ? ' - ' : timeSpent }}
</span>
</div>
</div>
</div>
</template>
<template #JSON>
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="ghost"
@click="refreshWorldDialogTreeData()">
<RefreshCw />
</Button>
<Button
class="rounded-full"
size="icon-sm"
variant="ghost"
@click="downloadAndSaveJson(worldDialog.id, worldDialog.ref)">
<Download />
</Button>
<vue-json-pretty
:key="treeData?.id"
:data="treeData"
:deep="2"
:theme="isDarkMode ? 'dark' : 'light'"
show-icon />
<br />
<vue-json-pretty
v-if="Object.keys(worldDialog.fileAnalysis).length"
:data="worldDialog.fileAnalysis"
:deep="2"
:theme="isDarkMode ? 'dark' : 'light'"
show-icon />
</template>
</TabsUnderline>
</div>
<template v-if="isDialogVisible">
<WorldAllowedDomainsDialog :world-allowed-domains-dialog="worldAllowedDomainsDialog" />
<SetWorldTagsDialog
v-model:is-set-world-tags-dialog-visible="isSetWorldTagsDialogVisible"
:old-tags="worldDialog.ref?.tags"
:world-id="worldDialog.id"
:is-world-dialog-visible="worldDialog.visible" />
<NewInstanceDialog
:new-instance-dialog-location-tag="newInstanceDialogLocationTag"
:last-location="lastLocation" />
<input
id="WorldImageUploadButton"
type="file"
accept="image/*"
style="display: none"
@change="onFileChangeWorldImage" />
<ImageCropDialog
:open="cropDialogOpen"
:title="t('dialog.change_content_image.world')"
:aspect-ratio="4 / 3"
:file="cropDialogFile"
@update:open="cropDialogOpen = $event"
@confirm="onCropConfirmWorld" />
</template>
</div>
</template>
<script setup>
import {
Apple,
Check,
ChevronDown,
Copy,
Download,
Ellipsis,
Eye,
Flag,
Home,
Image,
Languages,
LineChart,
MessageSquare,
Monitor,
MoreHorizontal,
Pencil,
RefreshCw,
Share2,
Smartphone,
Star,
Trash2,
Upload,
User,
Wand2
} from 'lucide-vue-next';
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue';
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import VueJsonPretty from 'vue-json-pretty';
import {
commaNumber,
compareUnityVersion,
deleteVRChatCache,
downloadAndSaveJson,
formatDateFilter,
openExternalLink,
openFolderGeneric,
refreshInstancePlayerCount,
replaceVrcPackageUrl,
timeToText,
userImage,
userStatusClass
} from '../../../shared/utils';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useFavoriteStore,
useGalleryStore,
useGameStore,
useInstanceStore,
useInviteStore,
useLocationStore,
useModalStore,
useUserStore,
useWorldStore
} from '../../../stores';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '../../ui/dropdown-menu';
import {
handleImageUploadInput,
readFileAsBase64,
resizeImageToFitLimits,
uploadImageLegacy,
withUploadTimeout
} from '../../../shared/utils/imageUpload';
import { favoriteRequest, miscRequest, userRequest, worldRequest } from '../../../api';
import { Badge } from '../../ui/badge';
import { database } from '../../../service/database';
import { formatJsonVars } from '../../../shared/utils/base/ui';
import ImageCropDialog from '../ImageCropDialog.vue';
import InstanceActionBar from '../../InstanceActionBar.vue';
const SetWorldTagsDialog = defineAsyncComponent(() => import('./SetWorldTagsDialog.vue'));
const WorldAllowedDomainsDialog = defineAsyncComponent(() => import('./WorldAllowedDomainsDialog.vue'));
const NewInstanceDialog = defineAsyncComponent(() => import('../NewInstanceDialog.vue'));
const { isAgeGatedInstancesVisible, isDarkMode } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { currentUser, userDialog } = storeToRefs(useUserStore());
const { worldDialog } = storeToRefs(useWorldStore());
const { cachedWorlds, showWorldDialog } = useWorldStore();
const { lastLocation } = storeToRefs(useLocationStore());
const { newInstanceSelfInvite, canOpenInstanceInGame } = useInviteStore();
const { showFavoriteDialog } = useFavoriteStore();
const { showPreviousInstancesInfoDialog, showPreviousInstancesListDialog: openPreviousInstancesListDialog } =
useInstanceStore();
const { instanceJoinHistory } = storeToRefs(useInstanceStore());
const { isGameRunning } = storeToRefs(useGameStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { translationApi } = storeToRefs(useAdvancedSettingsStore());
const modalStore = useModalStore();
const { t } = useI18n();
const worldDialogTabs = computed(() => [
{ value: 'Instances', label: t('dialog.world.instances.header') },
{ value: 'Info', label: t('dialog.world.info.header') },
{ value: 'JSON', label: t('dialog.world.json.header') }
]);
const treeData = ref({});
const worldAllowedDomainsDialog = ref({
visible: false,
worldId: '',
urlList: []
});
const isSetWorldTagsDialogVisible = ref(false);
const newInstanceDialogLocationTag = ref('');
const cropDialogOpen = ref(false);
const cropDialogFile = ref(null);
const changeWorldImageLoading = ref(false);
const translatedDescription = ref('');
const isTranslating = ref(false);
const isDialogVisible = computed({
get() {
return worldDialog.value.visible;
},
set(value) {
worldDialog.value.visible = value;
}
});
const isWindows = computed(() => WINDOWS);
const memo = computed({
get() {
return worldDialog.value.memo;
},
set(value) {
worldDialog.value.memo = value;
}
});
const isTimeInLabVisible = computed(() => {
return (
worldDialog.value.ref.publicationDate &&
worldDialog.value.ref.publicationDate !== 'none' &&
worldDialog.value.ref.labsPublicationDate &&
worldDialog.value.ref.labsPublicationDate !== 'none'
);
});
const timeInLab = computed(() => {
return timeToText(
new Date(worldDialog.value.ref.publicationDate).getTime() -
new Date(worldDialog.value.ref.labsPublicationDate).getTime()
);
});
const favoriteRate = computed(() => {
return (
Math.round(
(((worldDialog.value.ref?.favorites - worldDialog.value.ref?.visits) / worldDialog.value.ref?.visits) *
100 +
100) *
100
) / 100
);
});
const worldTags = computed(() => {
return worldDialog.value.ref?.tags
.filter((tag) => tag.startsWith('author_tag'))
.map((tag) => tag.replace('author_tag_', ''))
.join(', ');
});
const timeSpent = computed(() => {
return timeToText(worldDialog.value.timeSpent);
});
const worldDialogPlatform = computed(() => {
const { ref } = worldDialog.value;
const platforms = [];
if (ref.unityPackages) {
for (const unityPackage of ref.unityPackages) {
if (!compareUnityVersion(unityPackage.unitySortNumber)) {
continue;
}
let platform = 'PC';
if (unityPackage.platform === 'standalonewindows') {
platform = 'PC';
} else if (unityPackage.platform === 'android') {
platform = 'Android';
} else if (unityPackage.platform) {
platform = unityPackage.platform;
}
platforms.unshift(`${platform}/${unityPackage.unityVersion}`);
}
}
return platforms.join(', ');
});
const worldDialogPlatformCreatedAt = computed(() => {
const { ref } = worldDialog.value;
if (!ref.unityPackages) {
return null;
}
let newest = {};
for (const unityPackage of ref.unityPackages) {
if (unityPackage.variant && unityPackage.variant !== 'standard' && unityPackage.variant !== 'security') {
continue;
}
const platform = unityPackage.platform;
const createdAt = unityPackage.created_at;
if (!newest[platform] || new Date(createdAt) > new Date(newest[platform])) {
newest[platform] = createdAt;
}
}
return newest;
});
watch(
() => worldDialog.value.loading,
() => {
if (worldDialog.value.visible) {
handleDialogOpen();
!worldDialog.value.loading && loadLastActiveTab();
}
}
);
/**
*
* @param tabName
*/
function handleWorldDialogTab(tabName) {
worldDialog.value.lastActiveTab = tabName;
if (tabName === 'JSON') {
refreshWorldDialogTreeData();
}
}
/**
*
*/
function loadLastActiveTab() {
handleWorldDialogTab(worldDialog.value.lastActiveTab);
}
/**
*
* @param tabName
*/
function worldDialogTabClick(tabName) {
if (tabName === worldDialog.value.lastActiveTab) {
if (tabName === 'JSON') {
refreshWorldDialogTreeData();
}
return;
}
handleWorldDialogTab(tabName);
}
/**
*
*/
function handleDialogOpen() {
treeData.value = {};
}
/**
*
*/
function showChangeWorldImageDialog() {
document.getElementById('WorldImageUploadButton').click();
}
/**
*
* @param e
*/
function onFileChangeWorldImage(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#WorldImageUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
if (!worldDialog.value.visible || worldDialog.value.loading) {
clearInput();
return;
}
clearInput();
cropDialogFile.value = file;
cropDialogOpen.value = true;
}
/**
*
* @param blob
*/
async function onCropConfirmWorld(blob) {
changeWorldImageLoading.value = true;
try {
await withUploadTimeout(
(async () => {
const base64Body = await readFileAsBase64(blob);
const base64File = await resizeImageToFitLimits(base64Body);
await uploadImageLegacy('world', {
entityId: worldDialog.value.id,
imageUrl: worldDialog.value.ref.imageUrl,
base64File,
blob
});
})()
);
toast.success(t('message.upload.success'));
} catch (error) {
console.error('World image upload process failed:', error);
toast.error(t('message.upload.error'));
} finally {
changeWorldImageLoading.value = false;
cropDialogOpen.value = false;
}
}
/**
*
* @param tag
*/
function showNewInstanceDialog(tag) {
// trigger watcher
newInstanceDialogLocationTag.value = '';
nextTick(() => (newInstanceDialogLocationTag.value = tag));
}
/**
*
* @param command
*/
function worldDialogCommand(command) {
const D = worldDialog.value;
if (D.visible === false) {
return;
}
switch (command) {
case 'Delete Favorite':
case 'Make Home':
case 'Reset Home':
case 'Publish':
case 'Unpublish':
case 'Delete Persistent Data':
case 'Delete':
const commandLabelMap = {
'Delete Favorite': t('dialog.world.actions.favorites_tooltip'),
'Make Home': t('dialog.world.actions.make_home'),
'Reset Home': t('dialog.world.actions.reset_home'),
Publish: t('dialog.world.actions.publish_to_labs'),
Unpublish: t('dialog.world.actions.unpublish'),
'Delete Persistent Data': t('dialog.world.actions.delete_persistent_data'),
Delete: t('dialog.world.actions.delete')
};
modalStore
.confirm({
description: t('confirm.command_question', {
command: commandLabelMap[command] ?? command
}),
title: t('confirm.title')
})
.then(({ ok }) => {
if (!ok) return;
switch (command) {
case 'Delete Favorite':
favoriteRequest.deleteFavorite({
objectId: D.id
});
break;
case 'Make Home':
userRequest
.saveCurrentUser({
homeLocation: D.id
})
.then((args) => {
toast.success(t('message.world.home_updated'));
return args;
});
break;
case 'Reset Home':
userRequest
.saveCurrentUser({
homeLocation: ''
})
.then((args) => {
toast.success(t('message.world.home_reset'));
return args;
});
break;
case 'Publish':
worldRequest
.publishWorld({
worldId: D.id
})
.then((args) => {
toast.success(t('message.world.published'));
return args;
});
break;
case 'Unpublish':
worldRequest
.unpublishWorld({
worldId: D.id
})
.then((args) => {
toast.success(t('message.world.unpublished'));
return args;
});
break;
case 'Delete Persistent Data':
miscRequest
.deleteWorldPersistData({
worldId: D.id
})
.then((args) => {
if (args.params.worldId === worldDialog.value.id && worldDialog.value.visible) {
worldDialog.value.hasPersistData = false;
}
toast.success(t('message.world.persistent_data_deleted'));
return args;
});
break;
case 'Delete':
worldRequest
.deleteWorld({
worldId: D.id
})
.then((args) => {
const { json } = args;
cachedWorlds.delete(json.id);
if (worldDialog.value.ref.authorId === json.authorId) {
const map = new Map();
for (const ref of cachedWorlds.values()) {
if (ref.authorId === json.authorId) {
map.set(ref.id, ref);
}
}
const array = Array.from(map.values());
userDialog.value.worlds = array;
}
toast.success(t('message.world.deleted'));
D.visible = false;
return args;
});
break;
}
})
.catch(() => {});
break;
case 'Previous Instances':
showPreviousInstancesListDialog(D.ref);
break;
case 'Share':
copyWorldUrl();
break;
case 'Change Allowed Domains':
showWorldAllowedDomainsDialog();
break;
case 'Change Tags':
isSetWorldTagsDialogVisible.value = true;
break;
case 'Download Unity Package':
openExternalLink(replaceVrcPackageUrl(worldDialog.value.ref.unityPackageUrl));
break;
case 'Change Image':
showChangeWorldImageDialog();
break;
case 'Refresh':
const { tag, shortName } = worldDialog.value.$location;
showWorldDialog(tag, shortName, { forceRefresh: true });
break;
case 'New Instance':
showNewInstanceDialog(D.$location.tag);
break;
case 'Add Favorite':
showFavoriteDialog('world', D.id);
break;
case 'New Instance and Self Invite':
newInstanceSelfInvite(D.id);
break;
case 'Rename':
promptRenameWorld(D);
break;
case 'Change Description':
promptChangeWorldDescription(D);
break;
case 'Change Capacity':
promptChangeWorldCapacity(D);
break;
case 'Change Recommended Capacity':
promptChangeWorldRecommendedCapacity(D);
break;
case 'Change YouTube Preview':
promptChangeWorldYouTubePreview(D);
break;
default:
break;
}
}
/**
*
* @param world
*/
function promptRenameWorld(world) {
modalStore
.prompt({
title: t('prompt.rename_world.header'),
description: t('prompt.rename_world.description'),
confirmText: t('prompt.rename_world.ok'),
cancelText: t('prompt.rename_world.cancel'),
inputValue: world.ref.name,
errorMessage: t('prompt.rename_world.input_error')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value && value !== world.ref.name) {
worldRequest
.saveWorld({
id: world.id,
name: value
})
.then((args) => {
toast.success(t('prompt.rename_world.message.success'));
return args;
});
}
})
.catch(() => {});
}
/**
*
* @param world
*/
function promptChangeWorldDescription(world) {
modalStore
.prompt({
title: t('prompt.change_world_description.header'),
description: t('prompt.change_world_description.description'),
confirmText: t('prompt.change_world_description.ok'),
cancelText: t('prompt.change_world_description.cancel'),
inputValue: world.ref.description,
errorMessage: t('prompt.change_world_description.input_error')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value && value !== world.ref.description) {
worldRequest
.saveWorld({
id: world.id,
description: value
})
.then((args) => {
toast.success(t('prompt.change_world_description.message.success'));
return args;
});
}
})
.catch(() => {});
}
/**
*
* @param world
*/
function promptChangeWorldCapacity(world) {
modalStore
.prompt({
title: t('prompt.change_world_capacity.header'),
description: t('prompt.change_world_capacity.description'),
confirmText: t('prompt.change_world_capacity.ok'),
cancelText: t('prompt.change_world_capacity.cancel'),
inputValue: world.ref.capacity,
pattern: /\d+$/,
errorMessage: t('prompt.change_world_capacity.input_error')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value && value !== world.ref.capacity) {
worldRequest
.saveWorld({
id: world.id,
capacity: Number(value)
})
.then((args) => {
toast.success(t('prompt.change_world_capacity.message.success'));
return args;
});
}
})
.catch(() => {});
}
/**
*
* @param world
*/
function promptChangeWorldRecommendedCapacity(world) {
modalStore
.prompt({
title: t('prompt.change_world_recommended_capacity.header'),
description: t('prompt.change_world_recommended_capacity.description'),
confirmText: t('prompt.change_world_capacity.ok'),
cancelText: t('prompt.change_world_capacity.cancel'),
inputValue: world.ref.recommendedCapacity,
pattern: /\d+$/,
errorMessage: t('prompt.change_world_recommended_capacity.input_error')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value && value !== world.ref.recommendedCapacity) {
worldRequest
.saveWorld({
id: world.id,
recommendedCapacity: Number(value)
})
.then((args) => {
toast.success(t('prompt.change_world_recommended_capacity.message.success'));
return args;
});
}
})
.catch(() => {});
}
/**
*
* @param world
*/
function promptChangeWorldYouTubePreview(world) {
modalStore
.prompt({
title: t('prompt.change_world_preview.header'),
description: t('prompt.change_world_preview.description'),
confirmText: t('prompt.change_world_preview.ok'),
cancelText: t('prompt.change_world_preview.cancel'),
inputValue: world.ref.previewYoutubeId,
errorMessage: t('prompt.change_world_preview.input_error')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value && value !== world.ref.previewYoutubeId) {
let processedValue = value;
if (value.length > 11) {
try {
const url = new URL(value);
const id1 = url.pathname;
const id2 = url.searchParams.get('v');
if (id1 && id1.length === 12) {
processedValue = id1.substring(1, 12);
}
if (id2 && id2.length === 11) {
processedValue = id2;
}
} catch {
toast.error(t('prompt.change_world_preview.message.error'));
return;
}
}
if (processedValue !== world.ref.previewYoutubeId) {
worldRequest
.saveWorld({
id: world.id,
previewYoutubeId: processedValue
})
.then((args) => {
toast.success(t('prompt.change_world_preview.message.success'));
return args;
});
}
}
})
.catch(() => {});
}
/**
*
*/
function onWorldMemoChange() {
const worldId = worldDialog.value.id;
const memo = worldDialog.value.memo;
if (memo) {
database.setWorldMemo({
worldId,
editedAt: new Date().toJSON(),
memo
});
} else {
database.deleteWorldMemo(worldId);
}
}
/**
*
* @param worldRef
*/
function showPreviousInstancesListDialog(worldRef) {
openPreviousInstancesListDialog('world', worldRef);
}
/**
*
*/
function refreshWorldDialogTreeData() {
treeData.value = formatJsonVars(worldDialog.value.ref);
}
/**
*
*/
function copyWorldId() {
navigator.clipboard
.writeText(worldDialog.value.id)
.then(() => {
toast.success(t('message.world.id_copied'));
})
.catch((err) => {
console.error('copy failed:', err);
toast.error(t('message.copy_failed'));
});
}
/**
*
*/
function copyWorldUrl() {
navigator.clipboard
.writeText(`https://vrchat.com/home/world/${worldDialog.value.id}`)
.then(() => {
toast.success(t('message.world.url_copied'));
})
.catch((err) => {
console.error('copy failed:', err);
toast.error(t('message.copy_failed'));
});
}
/**
*
*/
function copyWorldName() {
navigator.clipboard
.writeText(worldDialog.value.ref.name)
.then(() => {
toast.success(t('message.world.name_copied'));
})
.catch((err) => {
console.error('copy failed:', err);
toast.error(t('message.copy_failed'));
});
}
/**
*
*/
function showWorldAllowedDomainsDialog() {
const D = worldAllowedDomainsDialog.value;
D.worldId = worldDialog.value.id;
D.urlList = worldDialog.value.ref?.urlList ?? [];
D.visible = true;
}
/**
*
*/
async function translateDescription() {
if (isTranslating.value) return;
const description = worldDialog.value.ref.description;
if (!description) return;
// Toggle: if already translated, clear to show original
if (translatedDescription.value) {
translatedDescription.value = '';
return;
}
isTranslating.value = true;
try {
const translated = await translateText(description, bioLanguage.value);
if (!translated) {
throw new Error('No translation returned');
}
translatedDescription.value = translated;
} catch (error) {
console.error('Translation failed:', error);
} finally {
isTranslating.value = false;
}
}
watch(
() => [worldDialog.value.id, worldDialog.value.ref?.description],
() => {
translatedDescription.value = '';
}
);
</script>