fix(stage-ui): rollback to previous implementation of model selector

This commit is contained in:
Neko Ayaka
2026-05-05 04:17:17 +08:00
parent 8a88d233f2
commit a9ed0c180c
2 changed files with 308 additions and 389 deletions
@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { reactive } from 'vue'
import Live2DReportModal from './Live2DReportModal.vue'
interface ReportVariantState {
open: boolean
events: string[]
}
const validReport: Live2DValidationReport = {
fileName: 'hiyori-production.model.zip',
totalFiles: 42,
status: 'VALID',
entryPoint: 'Hiyori/Hiyori.model3.json',
structureType: 'Standard (model3.json)',
errors: [],
warnings: [],
checks: [
'Entry point identified: Hiyori/Hiyori.model3.json',
'MOC3 Header Valid (Sub-version: 5, Size: 8.74 MB)',
],
mocInfo: {
header: 'MOC3',
ver: 5,
size: 9164554,
},
}
const warningReport: Live2DValidationReport = {
fileName: 'elena-concert-heavy.zip',
totalFiles: 186,
status: 'WARNING',
entryPoint: 'model/elena.model3.json',
structureType: 'Standard (model3.json)',
errors: [],
warnings: [
'HEAVY RESOURCE: MOC file is 45.28 MB. This may cause performance issues in web browsers.',
'Missing preview image. AIRI can still import this model, but the selector card will use a fallback preview.',
],
checks: [
'Entry point identified: model/elena.model3.json',
'MOC3 Header Valid (Sub-version: 5, Size: 45.28 MB)',
],
mocInfo: {
header: 'MOC3',
ver: 5,
size: 47479521,
},
}
const invalidReport: Live2DValidationReport = {
fileName: 'broken-archive.zip',
totalFiles: 17,
status: 'INVALID',
entryPoint: null,
structureType: 'Unknown',
errors: [
'Invalid Structure: No .model3.json found and 0 .moc3 files encountered.',
'Missing thumbnail referenced by model settings.',
'BASENAME COLLISION: Filename "texture_00.png" exists in multiple locations: model/textures/texture_00.png, model/expressions/texture_00.png. This causes data loss in AIRI\'s loader.',
],
warnings: [
'Archive contains loose files at the root. Put the model files in one folder before zipping.',
],
checks: [],
}
const validState = reactive<ReportVariantState>({ open: true, events: [] })
const warningState = reactive<ReportVariantState>({ open: true, events: [] })
const invalidState = reactive<ReportVariantState>({ open: true, events: [] })
function recordEvent(state: ReportVariantState, event: string) {
state.events = [event, ...state.events].slice(0, 4)
}
</script>
<template>
<Story
title="Dialogs / Live2D Report Modal"
group="dialogs"
>
<Variant
id="valid"
title="Valid Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="validState.open = true">
Open Valid Report
</Button>
<div
v-if="validState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in validState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="validState.open"
:report="validReport"
@close="recordEvent(validState, 'close')"
@confirm="recordEvent(validState, 'confirm')"
@fix-error="error => recordEvent(validState, `fixError: ${error}`)"
/>
</div>
</Variant>
<Variant
id="warning"
title="Warning Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="warningState.open = true">
Open Warning Report
</Button>
<div
v-if="warningState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in warningState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="warningState.open"
:report="warningReport"
@close="recordEvent(warningState, 'close')"
@confirm="recordEvent(warningState, 'confirm')"
@fix-error="error => recordEvent(warningState, `fixError: ${error}`)"
/>
</div>
</Variant>
<Variant
id="invalid"
title="Invalid Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="invalidState.open = true">
Open Invalid Report
</Button>
<div
v-if="invalidState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in invalidState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="invalidState.open"
:report="invalidReport"
@close="recordEvent(invalidState, 'close')"
@confirm="recordEvent(invalidState, 'confirm')"
@fix-error="error => recordEvent(invalidState, `fixError: ${error}`)"
/>
</div>
</Variant>
</Story>
</template>
@@ -3,13 +3,12 @@ import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import type { DisplayModel } from '../../../../stores/display-models'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { validateLive2DZip } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, EditableArea, EditableEditTrigger, EditableInput, EditablePreview, EditableRoot, EditableSubmitTrigger } from 'reka-ui'
import { ref, watch } from 'vue'
import Live2DReportModal from './Live2DReportModal.vue'
@@ -26,91 +25,19 @@ const emits = defineEmits<{
const displayModelStore = useDisplayModelsStore()
const { displayModelsFromIndexedDBLoading, displayModels } = storeToRefs(displayModelStore)
// Redesign State
const viewMode = ref<'grid' | 'compact'>('compact')
const searchQuery = ref('')
const formatFilter = ref<'all' | 'live2d' | 'vrm'>('all')
const sortBy = ref<'name' | 'date'>('date')
const showRenameDialog = ref(false)
const modelToRename = ref<DisplayModel | null>(null)
const tempRenameValue = ref('')
function handleRemoveModel(model: DisplayModel) {
displayModelStore.removeDisplayModel(model.id)
}
const highlightDisplayModelCard = ref<string | undefined>(props.selectedModel?.id)
const showReportModal = ref(false)
const pendingFile = ref<File | null>(null)
const validationReport = ref<Live2DValidationReport | null>(null)
const currentTab = ref<'library' | 'explore'>('library')
const marketplaces = [
{ name: 'Booth', vrm: true, live2d: true, languages: ['jp', 'us'], origin: 'Japan', url: 'https://booth.pm/en/browse/VTuber' },
{ name: 'VGen', vrm: true, live2d: true, languages: ['us'], origin: 'USA', url: 'https://vgen.co' },
{ name: 'itch.io', vrm: true, live2d: true, languages: ['us'], origin: 'USA', url: 'https://itch.io/game-assets' },
{ name: 'Gumroad', vrm: true, live2d: true, languages: ['us'], origin: 'USA', url: 'https://gumroad.com' },
{ name: 'Ko-fi', vrm: true, live2d: true, languages: ['us'], origin: 'USA', url: 'https://ko-fi.com/shop' },
{ name: 'VRoid Hub', vrm: true, live2d: false, languages: ['jp', 'us'], origin: 'Japan', url: 'https://hub.vroid.com' },
{ name: 'Sketchfab', vrm: true, live2d: false, languages: ['us'], origin: 'USA', url: 'https://sketchfab.com' },
{ name: 'CGTrader', vrm: true, live2d: false, languages: ['us'], origin: 'USA', url: 'https://cgtrader.com' },
{ name: 'Nizima', vrm: false, live2d: true, languages: ['jp', 'us'], origin: 'Japan', url: 'https://nizima.com' },
{ name: 'Avatar Atelier', vrm: false, live2d: true, languages: ['us'], origin: 'USA', url: 'https://avataratelier.com' },
{ name: 'VTuberAvatars', vrm: false, live2d: true, languages: ['us'], origin: 'USA', url: 'https://vtuberavatars.com' },
]
// Filtering Logic
const filteredModels = computed(() => {
let result = [...displayModels.value]
// Search
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
result = result.filter(m => m.name.toLowerCase().includes(q))
}
// Format Filter
if (formatFilter.value !== 'all') {
result = result.filter((m) => {
if (formatFilter.value === 'live2d')
return m.format === DisplayModelFormat.Live2dZip || m.format === DisplayModelFormat.Live2dDirectory
if (formatFilter.value === 'vrm')
return m.format === DisplayModelFormat.VRM
return true
})
}
// Sort
result.sort((a, b) => {
if (sortBy.value === 'name')
return a.name.localeCompare(b.name)
if (sortBy.value === 'date')
return b.importedAt - a.importedAt
return 0
})
return result
})
function handleRemoveModel(model: DisplayModel) {
displayModelStore.removeDisplayModel(model.id)
}
watch(() => props.selectedModel?.id, (modelId) => {
highlightDisplayModelCard.value = modelId
}, { immediate: true })
function openRenameDialog(model: DisplayModel) {
modelToRename.value = model
tempRenameValue.value = model.name
showRenameDialog.value = true
}
function confirmRename() {
if (modelToRename.value && tempRenameValue.value.trim()) {
displayModelStore.renameDisplayModel(modelToRename.value.id, tempRenameValue.value.trim())
showRenameDialog.value = false
}
}
async function handleAddLive2DModel(file: FileList | null) {
if (file === null || file.length === 0)
return
@@ -123,17 +50,22 @@ async function handleAddLive2DModel(file: FileList | null) {
if (report.status === 'VALID' && report.errors.length === 0) {
confirmImport()
return
}
else {
showReportModal.value = true
}
showReportModal.value = true
}
function confirmImport() {
if (pendingFile.value) {
displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, pendingFile.value)
pendingFile.value = null
}
if (pendingFile.value === null)
return
displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, pendingFile.value)
pendingFile.value = null
}
function handleFixError(error: string) {
void error
}
function handlePick(m: DisplayModel) {
@@ -143,8 +75,7 @@ function handlePick(m: DisplayModel) {
}
function handleMobilePick() {
const model = displayModels.value.find(model => model.id === highlightDisplayModelCard.value)
emits('pick', model)
emits('pick', displayModels.value.find(model => model.id === highlightDisplayModelCard.value))
emits('close', undefined)
}
@@ -171,127 +102,38 @@ const vrmDialog = useFileDialog({ accept: '.vrm', multiple: false, reset: true }
live2dDialog.onChange(handleAddLive2DModel)
vrmDialog.onChange(handleAddVRMModel)
function handleFixError(err: string) {
// eslint-disable-next-line no-console
console.log('[Model Selector] Fixing error:', err)
// Logic to fix common errors (e.g. missing preview)
// For now, we provide guidance or mark as ignorable in the future
if (err.toLowerCase().includes('preview') || err.toLowerCase().includes('thumbnail') || err.toLowerCase().includes('icon')) {
// If it's a missing preview, we could generate a placeholder
// For this PR feedback, we just acknowledged the "Quick Fix" button existence
}
}
</script>
<template>
<div :class="['pt-4 sm:pt-0', 'gap-4 sm:gap-6', 'h-full flex flex-col']">
<div class="flex items-center">
<Live2DReportModal
v-model:open="showReportModal"
:report="validationReport"
@confirm="confirmImport"
@fix-error="handleFixError"
/>
<div pt="4 sm:0" gap="4 sm:6" h-full flex flex-col>
<Live2DReportModal
v-model:open="showReportModal"
:report="validationReport"
@confirm="confirmImport"
@fix-error="handleFixError"
/>
<!-- Rename Dialog -->
<DialogRoot v-model:open="showRenameDialog">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[10001] bg-black/50 backdrop-blur-sm" />
<DialogContent class="fixed left-1/2 top-1/2 z-[10001] max-w-md w-[90dvw] translate-x-[-50%] translate-y-[-50%] rounded-xl bg-white p-6 text-neutral-900 shadow-xl dark:bg-neutral-900 dark:text-neutral-100">
<DialogTitle class="text-lg font-bold">
Rename Model
</DialogTitle>
<div :class="['mt-4 flex flex-col gap-4', 'split-button-container']">
<input
v-model="tempRenameValue"
type="text"
class="w-full border border-neutral-200 rounded-lg bg-neutral-100 px-3 py-2 outline-none dark:border-neutral-800 dark:bg-neutral-800"
placeholder="Model Name"
@keyup.enter="confirmRename"
>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="showRenameDialog = false">
Cancel
</Button>
<Button @click="confirmRename">
Rename
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="text-xl font-bold">
Model Selector
</div>
<!-- Tab Navigation -->
<div class="flex rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800">
<button
:class="[
currentTab === 'library' ? 'bg-white dark:bg-neutral-700 shadow-sm' : 'opacity-50 hover:opacity-100',
'px-3 py-1 rounded-md transition-all text-sm font-bold flex items-center gap-1',
]"
@click="currentTab = 'library'"
>
<div class="i-solar:library-bold-duotone" />
Library
</button>
<button
:class="[
currentTab === 'explore' ? 'bg-white dark:bg-neutral-700 shadow-sm' : 'opacity-50 hover:opacity-100',
'px-3 py-1 rounded-md transition-all text-sm font-bold flex items-center gap-1',
]"
@click="currentTab = 'explore'"
>
<div class="i-solar:compass-bold-duotone" />
Explore
</button>
</div>
<div flex items-center>
<div w-full flex-1 text-xl>
Model Selector
</div>
<div class="flex items-center gap-2">
<!-- View Mode Toggle (Only for Library) -->
<div v-if="currentTab === 'library'" class="mr-2 flex rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800">
<button
:class="[
viewMode === 'grid' ? 'bg-white dark:bg-neutral-700 shadow-sm' : 'hover:bg-black/5 dark:hover:bg-white/5 opacity-50',
'p-1.5 rounded-md transition-all',
]"
aria-label="Grid View"
@click="viewMode = 'grid'"
>
<div class="i-solar:widget-2-bold-duotone" />
</button>
<button
:class="[
viewMode === 'compact' ? 'bg-white dark:bg-neutral-700 shadow-sm' : 'hover:bg-black/5 dark:hover:bg-white/5 opacity-50',
'p-1.5 rounded-md transition-all',
]"
aria-label="Compact Grid View"
@click="viewMode = 'compact'"
>
<div class="i-solar:list-bold-duotone" />
</button>
</div>
<DropdownMenuRoot v-if="currentTab === 'library'">
<div>
<DropdownMenuRoot>
<DropdownMenuTrigger
class="flex items-center justify-center gap-2 rounded-lg bg-neutral-400/20 px-3 py-1.5 backdrop-blur-sm transition-colors duration-200 ease-in-out active:bg-neutral-400/60 dark:bg-neutral-700/50 hover:bg-neutral-400/45 active:dark:bg-neutral-700/90 hover:dark:bg-neutral-700/65"
bg="neutral-400/20 hover:neutral-400/45 active:neutral-400/60 dark:neutral-700/50 hover:dark:neutral-700/65 active:dark:neutral-700/90"
flex items-center justify-center gap-1 rounded-lg px-2 py-1 backdrop-blur-sm
transition="colors duration-200 ease-in-out"
aria-label="Options for Display Models"
>
<div class="i-solar:add-circle-bold" />
<div class="font-bold">
Add Local
</div>
<div i-solar:add-circle-bold />
<div>Add</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="will-change-[opacity,transform] z-10000 max-w-45 border border-neutral-200 rounded-lg bg-neutral-100 p-0.5 text-neutral-900 shadow-md outline-none backdrop-blur-sm data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
class="will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade"
bg="neutral-100/50 dark:neutral-950/50"
transition="colors duration-200 ease-in-out"
backdrop-blur-sm
align="end"
side="bottom"
:side-offset="8"
@@ -306,7 +148,7 @@ function handleFixError(err: string) {
transition="colors duration-200 ease-in-out"
@click="live2dDialog.open()"
>
Live2D (.zip)
Live2D
</DropdownMenuItem>
<DropdownMenuItem
:class="[
@@ -317,99 +159,45 @@ function handleFixError(err: string) {
]"
transition="colors duration-200 ease-in-out" @click="vrmDialog.open()"
>
VRM (.vrm)
VRM
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</div>
<!-- Library Tab Content -->
<template v-if="currentTab === 'library'">
<!-- Search & Filter Bar -->
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-40 flex-1">
<div class="i-solar:magnifer-linear absolute left-3 top-1/2 translate-y-[-50%] opacity-50" />
<input
v-model="searchQuery"
type="text"
placeholder="Search models..."
class="w-full border border-transparent rounded-lg bg-neutral-100 py-1.5 pl-9 pr-3 outline-none transition-all focus:border-primary-400 dark:bg-neutral-800"
>
</div>
<!-- Format Filter -->
<select
v-model="formatFilter"
class="cursor-pointer border border-transparent rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium outline-none transition-all dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700"
>
<option value="all">
All Formats
</option>
<option value="live2d">
Live2D
</option>
<option value="vrm">
VRM
</option>
</select>
<!-- Sort -->
<select
v-model="sortBy"
class="cursor-pointer border border-transparent rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium outline-none transition-all dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700"
>
<option value="name">
Name (A-Z)
</option>
<option value="date">
Last Added
</option>
</select>
</div>
<div v-if="displayModelsFromIndexedDBLoading">
Loading display models...
</div>
<div
class="w-full lg:max-h-80dvh"
:class="[
viewMode === 'grid' ? 'flex flex-col gap-2 md:grid lg:grid-cols-2 md:grid-cols-1' : 'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2',
]"
>
<!-- (Rest of Library Model Grid) -->
<div v-if="displayModelsFromIndexedDBLoading">
Loading display models...
</div>
<div class="flex-1 overflow-x-auto overflow-y-hidden md:flex-none sm:overflow-x-hidden sm:overflow-y-scroll" h-full w-full>
<div class="w-full flex gap-2 md:grid lg:grid-cols-2 md:grid-cols-1 lg:max-h-80dvh">
<div
v-for="(model) of filteredModels"
v-for="(model) of displayModels"
:key="model.id"
class="group relative transition-all duration-200"
:class="[
viewMode === 'grid' ? 'block h-full w-full md:flex md:flex-row gap-2' : 'flex flex-col',
highlightDisplayModelCard === model.id ? 'z-10' : 'z-0',
]"
v-auto-animate
relative gap-2
class="block h-full w-full md:flex md:flex-row"
@click="() => highlightDisplayModelCard = model.id"
>
<!-- Options Menu -->
<div class="absolute right-2 top-2 z-10">
<div absolute left-3 top-4 z-1>
<DropdownMenuRoot>
<DropdownMenuTrigger
:class="[
'bg-neutral-900/40 group-hover:bg-neutral-900/60 dark:bg-neutral-950/40 group-hover:dark:bg-neutral-900/80',
viewMode === 'compact' ? 'h-5 w-5' : 'h-7 w-7',
'text-white flex items-center justify-center rounded-lg backdrop-blur-md transition-all duration-200 ease-in-out shadow-sm',
'bg-neutral-900/20 hover:bg-neutral-900/45 active:bg-neutral-900/60 dark:bg-neutral-950/50 hover:dark:bg-neutral-900/65 active:dark:bg-neutral-900/90',
]"
text="white"
h-7 w-7 flex items-center justify-center rounded-lg backdrop-blur-sm
transition="colors duration-200 ease-in-out"
aria-label="Options for Display Models"
@click.stop
>
<div :class="['i-solar:menu-dots-bold', viewMode === 'compact' ? 'text-xs' : 'text-base']" />
<div i-solar:menu-dots-bold />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:class="[
'will-change-[opacity,transform] z-10000 max-w-45 rounded-xl p-1 text-white shadow-2xl outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black',
'bg-neutral-900/90 dark:bg-neutral-100/90',
'backdrop-blur-xl border border-white/10 dark:border-black/10',
'will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 text-white shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black',
'bg-neutral-900/30 dark:bg-neutral-950/50',
'backdrop-blur-sm',
]"
transition="colors duration-200 ease-in-out"
align="start"
@@ -417,151 +205,85 @@ function handleFixError(err: string) {
:side-offset="4"
>
<DropdownMenuItem
class="relative flex cursor-pointer select-none items-center rounded-lg px-3 py-2 text-base leading-none outline-none data-[highlighted]:bg-white/10 sm:text-sm dark:data-[highlighted]:bg-black/10"
@click="openRenameDialog(model)"
:class="[
'relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none sm:text-sm',
'data-[highlighted]:bg-red-900/20 dark:data-[highlighted]:bg-red-100/20',
'text-white dark:text-white data-[highlighted]:text-red-200 dark:data-[highlighted]:text-red-200',
]"
transition="colors duration-200 ease-in-out"
>
<div class="flex items-center gap-2">
<div class="i-solar:pen-bold" />
<div>Rename</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem
class="relative flex cursor-pointer select-none items-center rounded-lg px-3 py-2 text-base text-red-400 font-semibold leading-none outline-none data-[highlighted]:bg-red-500/20 sm:text-sm"
@click="handleRemoveModel(model)"
>
<div class="flex items-center gap-2">
<div class="i-solar:trash-bin-minimalistic-bold-duotone" />
<button flex items-center gap-1 outline-none @click="handleRemoveModel(model)">
<div i-solar:trash-bin-minimalistic-bold-duotone />
<div>Remove</div>
</div>
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<!-- Preview Image Area -->
<div
class="relative cursor-pointer overflow-hidden transition-all duration-300"
:class="[
viewMode === 'grid' ? 'h-50 md:h-60 w-full md:w-45 lg:w-50 shrink-0' : 'aspect-[3/4] w-full',
]"
@click="handlePick(model)"
class="h-full min-w-80 w-full lg:min-h-60 md:min-w-70 sm:min-w-65"
aspect="12/16"
px-1 py-2
>
<img
v-if="model.previewImage"
:src="model.previewImage"
h-full w-full rounded-xl object-cover
loading="lazy"
:class="[
highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-500 shadow-lg' : 'ring-1 ring-white/10 dark:ring-black/10',
'group-hover:scale-105 transition-transform duration-500',
'h-full w-full rounded-xl object-cover',
'transition-all duration-200 ease-in-out',
highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-400' : 'ring-0 ring-transparent',
]"
>
<div
v-else
:class="['h-full w-full flex flex-col items-center justify-center gap-2 rounded-xl bg-neutral-200 dark:bg-neutral-800', highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-500 shadow-lg' : 'ring-1 ring-white/10 dark:ring-black/10']"
>
<div class="i-solar:question-square-bold-duotone text-4xl opacity-30" />
</div>
<!-- Hover Effects Overlay -->
<div
class="pointer-events-none absolute inset-0 flex items-end justify-center rounded-xl from-black/60 to-transparent bg-gradient-to-t p-3 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
>
<div :class="['text-white text-xs font-bold flex items-center gap-1', 'translate-y-2 group-hover:translate-y-0 transition-transform duration-300']">
<div class="i-solar:map-arrow-up-bold" />
Pick Model
<div v-else bg="neutral-100 dark:neutral-900" relative h-full w-full flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-400' : 'ring-0 ring-transparent']" transition="all duration-200 ease-in-out">
<div i-solar:question-square-bold-duotone text-4xl opacity-75 />
<div translate-y="100%" absolute top-0 flex flex-col translate-x--7 rotate-45 scale-250 gap-0 opacity-5>
<div text="sm sm:sm" translate-x-7 translate-y--2 text-nowrap>
unavailable Preview unavailable Preview
</div>
<div text="sm sm:sm" translate-x-0 translate-y--0 text-nowrap>
Preview unavailable Preview unavailable
</div>
<div text="sm sm:sm" translate-x--7 translate-y-2 text-nowrap>
unavailable Preview unavailable Preview
</div>
</div>
</div>
</div>
<!-- Labels Area -->
<div
class="flex flex-1 flex-col"
:class="[viewMode === 'grid' ? 'justify-between p-2' : 'p-1.5']"
>
<div class="w-full">
<div
class="font-bold transition-colors"
:class="[
viewMode === 'grid' ? 'text-lg line-clamp-2 leading-tight' : 'text-sm line-clamp-1',
highlightDisplayModelCard === model.id ? 'text-primary-500' : '',
]"
<div w-full flex flex-col>
<div w-full flex-1 px-2 py-4>
<EditableRoot
v-slot="{ isEditing }"
:default-value="model.name"
placeholder="Model Name..."
class="flex gap-2"
auto-resize
>
{{ model.name }}
</div>
<div
class="mt-1 flex items-center gap-1 opacity-60"
:class="[viewMode === 'grid' ? 'text-sm' : 'text-xs']"
>
<div v-if="model.format === DisplayModelFormat.VRM" class="i-solar:box-bold" />
<div v-else class="i-solar:mask-hachi-bold" />
<EditableArea class="w-[calc(100%-8px-1rem)] dark:text-white">
<EditablePreview class="line-clamp-1 w-[calc(100%-8px)] overflow-hidden text-ellipsis" />
<EditableInput class="w-[calc(100%-8px)]! placeholder:text-neutral-700 dark:placeholder:text-neutral-600" />
</EditableArea>
<EditableEditTrigger v-if="!isEditing">
<div i-solar:pen-2-line-duotone opacity-50 />
</EditableEditTrigger>
<div v-else class="flex gap-2">
<EditableSubmitTrigger>
<div i-solar:check-read-line-duotone opacity-50 />
</EditableSubmitTrigger>
</div>
</EditableRoot>
<div flex items-center gap-1 text="neutral-400 dark:neutral-600">
<div i-solar:tag-horizontal-bold />
<div>{{ mapFormatRenderer[model.format] }}</div>
</div>
</div>
<!-- Pick toggle button for Standard View only -->
<Button
v-if="viewMode === 'grid'"
variant="secondary"
class="mt-2 w-full !rounded-lg !py-1.5"
@click="handlePick(model)"
>
<Button class="hidden md:block" variant="secondary" @click="handlePick(model)">
Pick
</Button>
</div>
</div>
</div>
</template>
<!-- Explore Tab Content -->
<template v-else-if="currentTab === 'explore'">
<div class="flex-1 overflow-y-auto pb-4 pr-2">
<div v-auto-animate class="grid grid-cols-1 gap-4 lg:grid-cols-3 md:grid-cols-2">
<a
v-for="site in marketplaces"
:key="site.name"
:href="site.url"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-3 border border-transparent rounded-xl bg-neutral-100 p-4 shadow-sm transition-all duration-300 hover:border-primary-500/50 dark:bg-neutral-800/50 hover:bg-white hover:shadow-md dark:hover:bg-neutral-800"
>
<div class="flex items-start justify-between">
<div class="text-lg font-bold transition-colors group-hover:text-primary-500">{{ site.name }}</div>
<div class="i-solar:share-circle-bold-duotone text-primary-500 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<div class="flex flex-wrap gap-2">
<div v-if="site.vrm" class="border border-blue-500/20 rounded bg-blue-500/10 px-2 py-0.5 text-[10px] text-blue-500 font-bold">VRM</div>
<div v-if="site.live2d" class="border border-green-500/20 rounded bg-green-500/10 px-2 py-0.5 text-[10px] text-green-500 font-bold">LIVE2D</div>
</div>
<div class="mt-auto flex items-center justify-between border-t border-neutral-200 pt-2 dark:border-neutral-700">
<div class="flex items-center gap-1 text-xs opacity-50">
<div class="i-solar:globus-linear" />
{{ site.origin }}
</div>
<div class="flex gap-1">
<span v-for="lang in site.languages" :key="lang" class="text-xs">
{{ lang === 'jp' ? '日本語' : 'English' }}
</span>
</div>
</div>
</a>
</div>
<div class="mt-8 flex flex-col items-center gap-2 border border-primary-500/10 rounded-2xl bg-primary-500/5 p-6 text-center">
<div class="i-solar:info-circle-bold-duotone text-3xl text-primary-500" />
<div class="text-lg font-bold">
Know more resources?
</div>
<div class="max-w-sm text-sm opacity-70">
Help the community by suggesting more marketplaces for VRM and Live2D models!
</div>
<a href="https://github.com/moeru-ai/airi/issues" target="_blank" class="mt-2 rounded-lg bg-primary-500 px-4 py-2 text-white font-bold transition-colors hover:bg-primary-600">Suggest a Site</a>
</div>
</div>
</template>
</div>
<Button class="block md:hidden" @click="handleMobilePick()">
Confirm
</Button>