Files
VRCX/src/views/Tools/Gallery.vue
T
2026-01-19 11:50:25 +09:00

1196 lines
50 KiB
Vue

<template>
<div class="gallery-page x-container">
<div class="flex items-center gap-2 ml-2">
<Button variant="ghost" size="sm" class="mr-3" @click="goBack">
<ArrowLeft />
{{ t('nav_tooltip.tools') }}
</Button>
<span class="header">{{ t('dialog.gallery_icons.header') }}</span>
</div>
<TabsUnderline default-value="gallery" :items="galleryTabs" :unmount-on-hide="false">
<template #label-gallery>
<span>
{{ t('dialog.gallery_icons.gallery') }}
<span class="gallery-tab-count"> {{ galleryTable.length }}/64 </span>
</span>
</template>
<template #label-icons>
<span>
{{ t('dialog.gallery_icons.icons') }}
<span class="gallery-tab-count"> {{ VRCPlusIconsTable.length }}/64 </span>
</span>
</template>
<template #label-emojis>
<span>
{{ t('dialog.gallery_icons.emojis') }}
<span class="gallery-tab-count">
{{ emojiTable.length }}/{{ cachedConfigTyped.maxUserEmoji }}
</span>
</span>
</template>
<template #label-stickers>
<span>
{{ t('dialog.gallery_icons.stickers') }}
<span class="gallery-tab-count">
{{ stickerTable.length }}/{{ cachedConfigTyped.maxUserStickers }}
</span>
</span>
</template>
<template #label-prints>
<span>
{{ t('dialog.gallery_icons.prints') }}
<span class="gallery-tab-count"> {{ printTable.length }}/64 </span>
</span>
</template>
<template #label-inventory>
<span>
{{ t('dialog.gallery_icons.inventory') }}
<span class="gallery-tab-count">
{{ inventoryTable.length }}
</span>
</span>
</template>
<template #gallery>
<div>
<input
id="GalleryUploadButton"
type="file"
accept="image/*"
@change="onFileChangeGallery"
style="display: none" />
<span>{{ t('dialog.gallery_icons.recommended_image_size') }}: 1200x900px (4:3)</span>
<br />
<br />
<ButtonGroup>
<Button variant="outline" size="sm" @click="refreshGalleryTable">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!isLocalUserVrcPlusSupporter || isUploading"
@click="displayGalleryUpload">
<Upload />
{{ t('dialog.gallery_icons.upload') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!currentUser.profilePicOverride"
@click="setProfilePicOverride('')">
<X />
{{ t('dialog.gallery_icons.clear') }}
</Button>
</ButtonGroup>
<br />
<div
class="x-friend-item"
v-for="image in galleryTable"
:key="image.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<template v-if="image.versions && image.versions.length > 0">
<div
class="h-[200px] w-[200px] rounded-[20px] overflow-hidden"
v-if="image.versions[image.versions.length - 1].file.url"
@click="setProfilePicOverride(image.id)"
:class="compareCurrentProfilePic(image.id) ? 'cursor-default' : 'cursor-pointer'">
<img
class="h-full w-full rounded-[15px] object-cover"
:src="image.versions[image.versions.length - 1].file.url"
loading="lazy" />
</div>
<div class="float-right" style="margin-top: 5px">
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="
showFullscreenImageDialog(image.versions[image.versions.length - 1].file.url)
">
<Maximize2 />
</Button>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
@click="deleteGalleryImage(image.id)">
<Trash2 />
</Button>
</div>
</template>
</div>
</div>
</template>
<template #icons>
<div>
<input
id="VRCPlusIconUploadButton"
type="file"
accept="image/*"
@change="onFileChangeVRCPlusIcon"
style="display: none" />
<span>{{ t('dialog.gallery_icons.recommended_image_size') }}: 2048x2048px (1:1)</span>
<br />
<br />
<ButtonGroup>
<Button variant="outline" size="sm" @click="refreshVRCPlusIconsTable">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!isLocalUserVrcPlusSupporter || isUploading"
@click="displayVRCPlusIconUpload">
<Upload />
{{ t('dialog.gallery_icons.upload') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!currentUser.userIcon"
@click="setVRCPlusIcon('')">
<X />
{{ t('dialog.gallery_icons.clear') }}
</Button>
</ButtonGroup>
<br />
<div
class="x-friend-item"
v-for="image in VRCPlusIconsTable"
:key="image.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<template v-if="image.versions && image.versions.length > 0"
><div
class="h-[200px] w-[200px] rounded-[20px] overflow-hidden"
v-if="image.versions[image.versions.length - 1].file.url"
@click="setVRCPlusIcon(image.id)"
:class="compareCurrentVRCPlusIcon(image.id) ? 'cursor-default' : 'cursor-pointer'">
<img
class="h-full w-full rounded-[15px] object-cover"
:src="image.versions[image.versions.length - 1].file.url"
loading="lazy" />
</div>
<div class="float-right" style="margin-top: 5px">
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="
showFullscreenImageDialog(image.versions[image.versions.length - 1].file.url)
">
<Maximize2 />
</Button>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
@click="deleteVRCPlusIcon(image.id)"
><Trash2
/></Button></div
></template>
</div>
</div>
</template>
<template #emojis>
<div>
<input
id="EmojiUploadButton"
type="file"
accept="image/*"
@change="onFileChangeEmoji"
style="display: none" />
<span>{{ t('dialog.gallery_icons.recommended_image_size') }}: 1024x1024px (1:1)</span>
<br />
<br />
<div>
<ButtonGroup style="margin-right: 10px">
<Button variant="outline" size="sm" @click="refreshEmojiTable">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!isLocalUserVrcPlusSupporter || isUploading"
@click="displayEmojiUpload">
<Upload />
{{ t('dialog.gallery_icons.upload') }}
</Button>
</ButtonGroup>
<br />
<br />
<VirtualCombobox
v-model="emojiAnimationStyle"
:groups="emojiStylePickerGroups"
:placeholder="t('dialog.gallery_icons.emoji_animation_styles')"
:search-placeholder="t('dialog.gallery_icons.emoji_animation_styles')"
:clearable="false"
:close-on-select="true">
<template #item="{ item, selected }">
<div class="flex w-full items-center gap-2">
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-sm bg-black/5">
<img
class="h-full w-full object-cover"
:src="`${emojiAnimationStyleUrl}${item.fileName}`"
loading="lazy" />
</div>
<span class="truncate text-sm" v-text="item.label"></span>
<span v-if="selected" class="ml-auto opacity-70"></span>
</div>
</template>
</VirtualCombobox>
<label class="inline-flex items-center gap-2">
<Checkbox v-model="emojiAnimType" />
<span>{{ t('dialog.gallery_icons.emoji_animation_type') }}</span>
</label>
<template v-if="emojiAnimType">
<Button
size="sm"
variant="outline"
class="mr-3"
@click="openExternalLink('https://vrcemoji.com')">
{{ t('dialog.gallery_icons.create_animated_emoji') }}
</Button>
<span style="margin-right: 10px">{{ t('dialog.gallery_icons.emoji_animation_fps') }}</span>
<NumberField
v-model="emojiAnimFps"
:min="1"
:max="64"
:step="1"
:format-options="{ maximumFractionDigits: 0 }"
class="mr-2.5 w-28">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span style="margin-right: 10px">{{
t('dialog.gallery_icons.emoji_animation_frame_count')
}}</span>
<NumberField
v-model="emojiAnimFrameCount"
:min="2"
:max="64"
:step="1"
:format-options="{ maximumFractionDigits: 0 }"
class="mr-2.5 w-28">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<label class="inline-flex items-center gap-2" style="margin-left: 10px; margin-right: 10px">
<Checkbox v-model="emojiAnimLoopPingPong" />
<span>{{ t('dialog.gallery_icons.emoji_loop_pingpong') }}</span>
</label>
<br />
<br />
<span>{{ t('dialog.gallery_icons.flipbook_info') }}</span>
</template>
</div>
<br />
<div
class="x-friend-item"
v-for="image in emojiTable"
:key="image.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<template v-if="image.versions && image.versions.length > 0">
<div
class="h-[200px] w-[200px] rounded-[20px] overflow-hidden cursor-pointer"
v-if="image.versions[image.versions.length - 1].file.url"
@click="
showFullscreenImageDialog(
image.versions[image.versions.length - 1].file.url,
getEmojiFileName(image)
)
">
<Emoji
:imageUrl="image.versions[image.versions.length - 1].file.url"
:size="200"></Emoji>
</div>
<div style="display: inline-block; margin: 5px">
<span v-if="image.loopStyle === 'pingpong'">
<RefreshCw style="margin-right: 5px" />
</span>
<span style="margin-right: 5px">{{ image.animationStyle }}</span>
<span v-if="image.framesOverTime" style="margin-right: 5px"
>{{ image.framesOverTime }}fps</span
>
<span v-if="image.frames" style="margin-right: 5px">{{ image.frames }}frames</span>
<br />
</div>
<div class="float-right" style="margin-top: 5px">
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="
showFullscreenImageDialog(
image.versions[image.versions.length - 1].file.url,
getEmojiFileName(image)
)
"
><Maximize2
/></Button>
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="deleteEmoji(image.id)">
<Trash2
/></Button></div
></template>
</div>
</div>
</template>
<template #stickers>
<div>
<input
id="StickerUploadButton"
type="file"
accept="image/*"
@change="onFileChangeSticker"
style="display: none" />
<span>{{ t('dialog.gallery_icons.recommended_image_size') }}: 1024x1024px (1:1)</span>
<br />
<br />
<ButtonGroup>
<Button variant="outline" size="sm" @click="refreshStickerTable">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!isLocalUserVrcPlusSupporter || isUploading"
@click="displayStickerUpload">
<Upload />
{{ t('dialog.gallery_icons.upload') }}
</Button>
</ButtonGroup>
<br />
<div
class="x-friend-item"
v-for="image in stickerTable"
:key="image.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<template v-if="image.versions && image.versions.length > 0">
<div
class="h-[200px] w-[200px] rounded-[20px] overflow-hidden cursor-pointer"
v-if="image.versions[image.versions.length - 1].file.url"
@click="showFullscreenImageDialog(image.versions[image.versions.length - 1].file.url)">
<img
class="h-full w-full rounded-[15px] object-cover"
:src="image.versions[image.versions.length - 1].file.url"
loading="lazy" />
</div>
<div class="float-right" style="margin-top: 5px">
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="
showFullscreenImageDialog(image.versions[image.versions.length - 1].file.url)
"
><Maximize2
/></Button>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
@click="deleteSticker(image.id)"
><Trash2
/></Button></div
></template>
</div>
</div>
</template>
<template #prints>
<div>
<input
id="PrintUploadButton"
type="file"
accept="image/*"
@change="onFileChangePrint"
style="display: none" />
<span>{{ t('dialog.gallery_icons.recommended_image_size') }}: 1920x1080px (16:9)</span>
<br />
<br />
<div style="display: flex; align-items: center">
<ButtonGroup>
<Button variant="outline" size="sm" @click="refreshPrintTable">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!isLocalUserVrcPlusSupporter || isUploading"
@click="displayPrintUpload">
<Upload />
{{ t('dialog.gallery_icons.upload') }}
</Button>
</ButtonGroup>
<InputGroupTextareaField
v-model="printUploadNote"
:rows="1"
:maxlength="32"
style="margin-left: 10px; width: 300px"
:placeholder="t('dialog.gallery_icons.note')"
input-class="resize-none min-h-0" />
<label class="inline-flex items-center gap-2" style="margin-left: 10px; margin-right: 10px">
<Checkbox v-model="printCropBorder" />
<span>{{ t('dialog.gallery_icons.crop_print_border') }}</span>
</label>
</div>
<br />
<div
class="x-friend-item"
v-for="image in printTable"
:key="image.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<div
class="h-[200px] w-[200px] rounded-[20px] overflow-hidden cursor-pointer"
@click="showFullscreenImageDialog(image.files.image, getPrintFileName(image))">
<img
class="h-full w-full rounded-[15px] object-cover"
:src="image.files.image"
loading="lazy" />
</div>
<div style="margin-top: 5px; width: 208px">
<span class="block truncate" v-if="image.note" v-text="image.note"></span>
<span v-else class="block">&nbsp;</span>
<Location
class="block truncate"
v-if="image.worldId"
:location="image.worldId"
:hint="image.worldName" />
<span v-else class="block">&nbsp;</span>
<DisplayName
class="block truncate gallery-meta"
v-if="image.authorId"
:userid="image.authorId"
:hint="image.authorName" />
<span v-else class="gallery-meta">&nbsp;</span>
<span v-if="image.createdAt" class="block truncate gallery-meta gallery-meta--small">
{{ formatDateFilter(image.createdAt, 'long') }}
</span>
<span v-else class="block">&nbsp;</span>
</div>
<div class="float-right">
<Button
class="rounded-full mr-2"
size="icon-sm"
variant="outline"
@click="showFullscreenImageDialog(image.files.image, getPrintFileName(image))">
<Maximize2
/></Button>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
@click="deletePrint(image.id)">
<Trash2
/></Button>
</div>
</div>
</div>
</template>
<template #inventory>
<div>
<br />
<br />
<div style="display: flex; align-items: center">
<ButtonGroup>
<Button variant="outline" size="sm" @click="getInventory">
<RefreshCw />
{{ t('dialog.gallery_icons.refresh') }}
</Button>
<Button variant="outline" size="sm" @click="redeemReward">
<Gift />
{{ t('dialog.gallery_icons.redeem') }}
</Button>
</ButtonGroup>
</div>
<br />
<div
class="x-friend-item"
v-for="item in inventoryTable"
:key="item.id"
style="display: inline-block; margin-top: 10px; width: unset; cursor: default">
<div class="h-[200px] w-[200px] rounded-[20px] overflow-hidden cursor-default">
<img
class="h-full w-full rounded-[15px] object-cover"
:src="item.imageUrl"
loading="lazy" />
</div>
<div style="margin-top: 5px; width: 208px">
<span class="block truncate" v-text="item.name"></span>
<span v-if="item.description" class="block truncate" v-text="item.description"></span>
<span v-else class="block">&nbsp;</span>
<span class="block truncate gallery-meta gallery-meta--small">
{{ formatDateFilter(item.created_at, 'long') }}
</span>
<span v-if="item.itemType === 'prop'">{{ t('dialog.gallery_icons.item') }}</span>
<span v-else-if="item.itemType === 'sticker'">{{ t('dialog.gallery_icons.sticker') }}</span>
<span v-else-if="item.itemType === 'droneskin'">{{
t('dialog.gallery_icons.drone_skin')
}}</span>
<span v-else-if="item.itemType === 'emoji'">{{ t('dialog.gallery_icons.emoji') }}</span>
<span v-else v-text="item.itemTypeLabel"></span>
</div>
<Button
size="sm"
v-if="item.itemType === 'bundle'"
@click="consumeInventoryBundle(item.id)"
class="float-right">
{{ t('dialog.gallery_icons.consume_bundle') }}
</Button>
</div>
</div>
</template>
</TabsUnderline>
</div>
</template>
<script setup>
import { ArrowLeft, Gift, Maximize2, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput
} from '@/components/ui/number-field';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { Checkbox } from '@/components/ui/checkbox';
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { TabsUnderline } from '@/components/ui/tabs';
import { VirtualCombobox } from '@/components/ui/virtual-combobox';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import {
extractFileId,
formatDateFilter,
getEmojiFileName,
getPrintFileName,
openExternalLink
} from '../../shared/utils';
import { inventoryRequest, miscRequest, userRequest, vrcPlusIconRequest, vrcPlusImageRequest } from '../../api';
import { useAdvancedSettingsStore, useAuthStore, useGalleryStore, useModalStore, useUserStore } from '../../stores';
import { emojiAnimationStyleList, emojiAnimationStyleUrl } from '../../shared/constants';
import { AppDebug } from '../../service/appConfig';
import { handleImageUploadInput } from '../../shared/utils/imageUpload';
import Emoji from '../../components/Emoji.vue';
const { t } = useI18n();
const router = useRouter();
const modalStore = useModalStore();
const {
galleryTable,
galleryDialogVisible,
VRCPlusIconsTable,
printUploadNote,
printCropBorder,
stickerTable,
printTable,
emojiTable,
inventoryTable
} = storeToRefs(useGalleryStore());
const {
loadGalleryData,
refreshGalleryTable,
refreshVRCPlusIconsTable,
refreshStickerTable,
refreshPrintTable,
refreshEmojiTable,
getInventory,
handleStickerAdd,
handleGalleryImageAdd
} = useGalleryStore();
const { currentUserInventory } = storeToRefs(useAdvancedSettingsStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { currentUser, isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
const { cachedConfig } = storeToRefs(useAuthStore());
const cachedConfigTyped = computed(
() => /** @type {{ maxUserEmoji?: number, maxUserStickers?: number }} */ (cachedConfig.value ?? {})
);
const galleryTabs = computed(() => [
{ value: 'gallery', label: t('dialog.gallery_icons.gallery') },
{ value: 'icons', label: t('dialog.gallery_icons.icons') },
{ value: 'emojis', label: t('dialog.gallery_icons.emojis') },
{ value: 'stickers', label: t('dialog.gallery_icons.stickers') },
{ value: 'prints', label: t('dialog.gallery_icons.prints') },
{ value: 'inventory', label: t('dialog.gallery_icons.inventory') }
]);
const emojiAnimFps = ref(15);
const emojiAnimFrameCount = ref(4);
const emojiAnimType = ref(false);
const emojiAnimationStyle = ref('Stop');
const emojiAnimLoopPingPong = ref(false);
const emojiStylePickerGroups = computed(() => [
{
key: 'emojiAnimationStyles',
label: t('dialog.gallery_icons.emoji_animation_styles'),
items: Object.entries(emojiAnimationStyleList).map(([styleName, fileName]) => ({
value: styleName,
label: styleName,
search: styleName,
fileName
}))
}
]);
const pendingUploads = ref(0);
const isUploading = computed(() => pendingUploads.value > 0);
onMounted(() => {
galleryDialogVisible.value = true;
loadGalleryData();
});
onBeforeUnmount(() => {
galleryDialogVisible.value = false;
});
function startUpload() {
pendingUploads.value += 1;
}
function finishUpload() {
pendingUploads.value = Math.max(0, pendingUploads.value - 1);
}
function goBack() {
galleryDialogVisible.value = false;
router.push({ name: 'tools' });
}
function onFileChangeGallery(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#GalleryUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
startUpload();
const r = new FileReader();
const handleReaderError = () => finishUpload();
r.onerror = handleReaderError;
r.onabort = handleReaderError;
r.onload = function () {
try {
const base64Body = btoa(r.result.toString());
const uploadPromise = vrcPlusImageRequest.uploadGalleryImage(base64Body).then((args) => {
handleGalleryImageAdd(args);
return args;
});
toast.promise(uploadPromise, {
loading: t('message.upload.loading'),
success: t('message.upload.success'),
error: t('message.upload.error')
});
uploadPromise
.catch((error) => {
console.error('Failed to upload', error);
})
.finally(() => finishUpload());
} catch (error) {
finishUpload();
console.error('Failed to process image', error);
}
};
try {
r.readAsBinaryString(file);
} catch (error) {
clearInput();
finishUpload();
console.error('Failed to read file', error);
}
clearInput();
}
function displayGalleryUpload() {
document.getElementById('GalleryUploadButton').click();
}
function setProfilePicOverride(fileId) {
if (!isLocalUserVrcPlusSupporter.value) {
toast.error(t('message.vrcplus.required'));
return;
}
let profilePicOverride = '';
if (fileId) {
profilePicOverride = `${AppDebug.endpointDomain}/file/${fileId}/1`;
}
if (profilePicOverride === currentUser.value.profilePicOverride) {
return;
}
userRequest
.saveCurrentUser({
profilePicOverride
})
.then((args) => {
toast.success('Profile picture changed');
return args;
});
}
function compareCurrentProfilePic(fileId) {
const currentProfilePicOverride = extractFileId(currentUser.value.profilePicOverride);
if (fileId === currentProfilePicOverride) {
return true;
}
return false;
}
function deleteGalleryImage(fileId) {
miscRequest.deleteFile(fileId).then((args) => {
const array = galleryTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (args.fileId === array[i].id) {
array.splice(i, 1);
break;
}
}
return args;
});
}
function onFileChangeVRCPlusIcon(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#VRCPlusIconUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
startUpload();
const r = new FileReader();
const handleReaderError = () => finishUpload();
r.onerror = handleReaderError;
r.onabort = handleReaderError;
r.onload = function () {
try {
const base64Body = btoa(r.result.toString());
const uploadPromise = vrcPlusIconRequest.uploadVRCPlusIcon(base64Body).then((args) => {
if (Object.keys(VRCPlusIconsTable.value).length !== 0) {
VRCPlusIconsTable.value.unshift(args.json);
}
return args;
});
toast.promise(uploadPromise, {
loading: t('message.upload.loading'),
success: t('message.upload.success'),
error: t('message.upload.error')
});
uploadPromise
.catch((error) => {
console.error('Failed to upload VRC+ icon', error);
})
.finally(() => finishUpload());
} catch (error) {
finishUpload();
console.error('Failed to process upload', error);
}
};
try {
r.readAsBinaryString(file);
} catch (error) {
clearInput();
finishUpload();
console.error('Failed to read file', error);
}
clearInput();
}
function displayVRCPlusIconUpload() {
document.getElementById('VRCPlusIconUploadButton').click();
}
function setVRCPlusIcon(fileId) {
if (!isLocalUserVrcPlusSupporter.value) {
toast.error(t('message.vrcplus.required'));
return;
}
let userIcon = '';
if (fileId) {
userIcon = `${AppDebug.endpointDomain}/file/${fileId}/1`;
}
if (userIcon === currentUser.value.userIcon) {
return;
}
userRequest
.saveCurrentUser({
userIcon
})
.then((args) => {
toast.success('Icon changed');
return args;
});
}
function compareCurrentVRCPlusIcon(userIcon) {
const currentUserIcon = extractFileId(currentUser.value.userIcon);
if (userIcon === currentUserIcon) {
return true;
}
return false;
}
function deleteVRCPlusIcon(fileId) {
miscRequest.deleteFile(fileId).then((args) => {
const array = VRCPlusIconsTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (args.fileId === array[i].id) {
array.splice(i, 1);
break;
}
}
return args;
});
}
function parseEmojiFileName(fileName) {
// remove file extension
fileName = fileName.replace(/\.[^/.]+$/, '');
const array = fileName.split('_');
for (let i = 0; i < array.length; ++i) {
const value = array[i];
if (value.endsWith('animationStyle')) {
emojiAnimType.value = false;
emojiAnimationStyle.value = value.replace('animationStyle', '').toLowerCase();
}
if (value.endsWith('frames')) {
emojiAnimType.value = true;
emojiAnimFrameCount.value = parseInt(value.replace('frames', ''));
}
if (value.endsWith('fps')) {
emojiAnimFps.value = parseInt(value.replace('fps', ''));
}
if (value.endsWith('loopStyle')) {
emojiAnimLoopPingPong.value = value.replace('loopStyle', '').toLowerCase();
}
}
}
function onFileChangeEmoji(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#EmojiUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
startUpload();
// set Emoji settings from fileName
parseEmojiFileName(file.name);
const r = new FileReader();
const handleReaderError = () => finishUpload();
r.onerror = handleReaderError;
r.onabort = handleReaderError;
r.onload = function () {
try {
const params = {
tag: emojiAnimType.value ? 'emojianimated' : 'emoji',
animationStyle: emojiAnimationStyle.value.toLowerCase(),
maskTag: 'square'
};
if (emojiAnimType.value) {
params.frames = emojiAnimFrameCount.value;
params.framesOverTime = emojiAnimFps.value;
}
if (emojiAnimLoopPingPong.value) {
params.loopStyle = 'pingpong';
}
const base64Body = btoa(r.result.toString());
const uploadPromise = vrcPlusImageRequest.uploadEmoji(base64Body, params).then((args) => {
if (Object.keys(emojiTable.value).length !== 0) {
emojiTable.value.unshift(args.json);
}
return args;
});
toast.promise(uploadPromise, {
loading: t('message.upload.loading'),
success: t('message.upload.success'),
error: t('message.upload.error')
});
uploadPromise
.catch((error) => {
console.error('Failed to upload', error);
})
.finally(() => finishUpload());
} catch (error) {
finishUpload();
console.error('Failed to process upload', error);
}
};
try {
r.readAsBinaryString(file);
} catch (error) {
clearInput();
finishUpload();
console.error('Failed to read file', error);
}
clearInput();
}
function displayEmojiUpload() {
document.getElementById('EmojiUploadButton').click();
}
function deleteEmoji(fileId) {
miscRequest.deleteFile(fileId).then((args) => {
const array = emojiTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (args.fileId === array[i].id) {
array.splice(i, 1);
break;
}
}
return args;
});
}
function onFileChangeSticker(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#StickerUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
startUpload();
const r = new FileReader();
const handleReaderError = () => finishUpload();
r.onerror = handleReaderError;
r.onabort = handleReaderError;
r.onload = function () {
try {
const params = {
tag: 'sticker',
maskTag: 'square'
};
const base64Body = btoa(r.result.toString());
const uploadPromise = vrcPlusImageRequest.uploadSticker(base64Body, params).then((args) => {
handleStickerAdd(args);
return args;
});
toast.promise(uploadPromise, {
loading: t('message.upload.loading'),
success: t('message.upload.success'),
error: t('message.upload.error')
});
uploadPromise
.catch((error) => {
console.error('Failed to upload', error);
})
.finally(() => finishUpload());
} catch (error) {
finishUpload();
console.error('Failed to process upload', error);
}
};
try {
r.readAsBinaryString(file);
} catch (error) {
clearInput();
finishUpload();
console.error('Failed to read file', error);
}
clearInput();
}
function displayStickerUpload() {
document.getElementById('StickerUploadButton').click();
}
function deleteSticker(fileId) {
miscRequest.deleteFile(fileId).then((args) => {
const array = stickerTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (args.fileId === array[i].id) {
array.splice(i, 1);
break;
}
}
return args;
});
}
function onFileChangePrint(e) {
const { file, clearInput } = handleImageUploadInput(e, {
inputSelector: '#PrintUploadButton',
tooLargeMessage: () => t('message.file.too_large'),
invalidTypeMessage: () => t('message.file.not_image')
});
if (!file) {
return;
}
startUpload();
const r = new FileReader();
const handleReaderError = () => finishUpload();
r.onerror = handleReaderError;
r.onabort = handleReaderError;
r.onload = function () {
try {
const date = new Date();
// why the fuck isn't this UTC
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
const timestamp = date.toISOString().slice(0, 19);
const params = {
note: printUploadNote.value,
// worldId: '',
timestamp
};
const base64Body = btoa(r.result.toString());
const cropWhiteBorder = printCropBorder.value;
const uploadPromise = vrcPlusImageRequest
.uploadPrint(base64Body, cropWhiteBorder, params)
.then((args) => {
if (Object.keys(printTable.value).length !== 0) {
printTable.value.unshift(args.json);
}
return args;
});
toast.promise(uploadPromise, {
loading: t('message.upload.loading'),
success: t('message.upload.success'),
error: t('message.upload.error')
});
uploadPromise
.catch((error) => {
console.error('Failed to upload', error);
})
.finally(() => finishUpload());
} catch (error) {
finishUpload();
console.error('Failed to process upload', error);
}
};
try {
r.readAsBinaryString(file);
} catch (error) {
clearInput();
finishUpload();
console.error('Failed to read file', error);
}
clearInput();
}
function displayPrintUpload() {
document.getElementById('PrintUploadButton').click();
}
function deletePrint(printId) {
vrcPlusImageRequest.deletePrint(printId).then((args) => {
const array = printTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (args.printId === array[i].id) {
array.splice(i, 1);
break;
}
}
});
}
async function consumeInventoryBundle(inventoryId) {
try {
await inventoryRequest.consumeInventoryBundle({
inventoryId
});
currentUserInventory.value.delete(inventoryId);
const array = inventoryTable.value;
const { length } = array;
for (let i = 0; i < length; ++i) {
if (inventoryId === array[i].id) {
array.splice(i, 1);
break;
}
}
getInventory();
} catch (error) {
console.error('Error consuming inventory bundle:', error);
}
// -- response --
// errors: []
// inventoryItems : []
// inventoryItemsCreated: 0
}
async function redeemReward() {
modalStore
.prompt({
title: t('prompt.redeem.header'),
description: t('prompt.redeem.description'),
confirmText: t('prompt.redeem.redeem'),
cancelText: t('prompt.redeem.cancel')
})
.then(({ ok, value }) => {
if (!ok) return;
if (value) {
inventoryRequest
.redeemReward({
code: value.trim()
})
.then((args) => {
toast.success(t('prompt.redeem.success'));
getInventory();
return args;
})
.catch((error) => {
console.error('Error redeeming reward:', error);
});
}
})
.catch(() => {});
}
</script>
<style scoped>
.gallery-tab-count {
font-size: 12px;
margin-left: 5px;
}
.gallery-meta {
font-family: monospace;
display: block;
}
.gallery-meta--small {
font-size: 11px;
}
</style>