feat(stage-ui): input file component

This commit is contained in:
Neko Ayaka
2025-04-09 13:38:46 +08:00
parent 1d84cb298e
commit 785a867d1b
8 changed files with 222 additions and 121 deletions
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { InputFile } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -28,8 +29,25 @@ const searchQuery = ref('')
// Sort option
const sortOption = ref('nameAsc')
// Drag and drop
const isDragging = ref(false)
const inputFiles = ref<File[]>([])
watch(inputFiles, async (newFiles) => {
const file = newFiles[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
})
// Card list data structure
interface CardItem {
@@ -94,35 +112,6 @@ function confirmDelete(id: string) {
showDeleteConfirm.value = true
}
/**
* Handles card file upload and processing
*/
async function handleUpload() {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.json'
fileInput.onchange = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
}
fileInput.click()
}
function handleSelectCard(cardId: string) {
selectedCardId.value = cardId
isCardDialogOpen.value = true
@@ -229,35 +218,27 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
:class="{ 'grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 grid-auto-rows-[minmax(min-content,max-content)] grid-auto-flow-dense sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] sm:gap-5 md:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(250px,1fr))]': cards.size > 0 }"
>
<!-- Upload card -->
<div
class="relative min-h-[120px] flex flex-col cursor-pointer items-center justify-center border-2 rounded-xl border-dashed p-6 transition-all duration-300"
border="neutral-200 dark:neutral-700 hover:primary-300 dark:hover:primary-700"
bg="white/60 dark:black/30 hover:white/80 dark:hover:black/40"
:style="{ transform: 'scale(0.98)', opacity: 0.95 }"
hover="scale-100 opacity-100 shadow-md dark:shadow-xl"
@click="handleUpload"
>
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="center neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
<p text="center neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}
</p>
<!-- Drag overlay -->
<div
v-if="isDragging"
class="bg-primary-100/80 border-primary-400 dark:bg-primary-900/80 dark:border-primary-600 absolute inset-0 flex items-center justify-center border-2 rounded-xl"
>
<div class="text-center">
<div i-solar:upload-minimalistic-bold class="dark:text-primary-400 text-primary-500 mb-2 text-5xl" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
<InputFile v-model="inputFiles" accept="*.json">
<template #default="{ isDragging }">
<template v-if="!isDragging">
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="center neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
</div>
</div>
</div>
<p text="center neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}) }}
</p>
</template>
<template v-else>
<div class="text-center">
<div i-solar:upload-minimalistic-bold class="text-primary-500 dark:text-primary-400 mb-2 text-5xl" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
</p>
</div>
</template>
</template>
</InputFile>
<!-- Card Items -->
<template v-if="cards.size > 0">
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { InputFile } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -28,8 +29,7 @@ const searchQuery = ref('')
// Sort option
const sortOption = ref('nameAsc')
// Drag and drop
const isDragging = ref(false)
const inputFiles = ref<File[]>([])
// Card list data structure
interface CardItem {
@@ -40,6 +40,24 @@ interface CardItem {
customizable?: boolean
}
watch(inputFiles, async (newFiles) => {
const file = newFiles[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
})
// Transform cards Map to array for display
const cardsArray = computed<CardItem[]>(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({
@@ -94,35 +112,6 @@ function confirmDelete(id: string) {
showDeleteConfirm.value = true
}
/**
* Handles card file upload and processing
*/
async function handleUpload() {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.json'
fileInput.onchange = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
}
catch (error) {
console.error('Error processing card file:', error)
}
}
fileInput.click()
}
function handleSelectCard(cardId: string) {
selectedCardId.value = cardId
isCardDialogOpen.value = true
@@ -229,35 +218,29 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
:class="{ 'grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 grid-auto-rows-[minmax(min-content,max-content)] grid-auto-flow-dense sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] sm:gap-5 md:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(250px,1fr))]': cards.size > 0 }"
>
<!-- Upload card -->
<div
class="relative min-h-[120px] flex flex-col cursor-pointer items-center justify-center border-2 rounded-xl border-dashed p-6 transition-all duration-300"
border="neutral-200 dark:neutral-700 hover:primary-300 dark:hover:primary-700"
bg="white/60 dark:black/30 hover:white/80 dark:hover:black/40"
:style="{ transform: 'scale(0.98)', opacity: 0.95 }"
hover="scale-100 opacity-100 shadow-md dark:shadow-xl"
@click="handleUpload"
>
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="center neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
<p text="center neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}
</p>
<!-- Drag overlay -->
<div
v-if="isDragging"
class="bg-primary-100/80 border-primary-400 dark:bg-primary-900/80 dark:border-primary-600 absolute inset-0 flex items-center justify-center border-2 rounded-xl"
>
<div class="text-center">
<div i-solar:upload-minimalistic-bold class="text-primary-500 dark:text-primary-400 mb-2 text-5xl" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
</p>
</div>
</div>
</div>
<InputFile v-model="inputFiles" accept="*.json">
<template #default="{ isDragging }">
<template v-if="!isDragging">
<div flex flex-col items-center>
<div i-solar:upload-square-line-duotone mb-4 text-5xl text="neutral-400 dark:neutral-500" />
<p font-medium text="neutral-600 dark:neutral-300">
{{ t('settings.pages.card.upload') }}
</p>
<p text="neutral-500 dark:neutral-400" mt-2 text-sm>
{{ t('settings.pages.card.upload_desc') }}
</p>
</div>
</template>
<template v-else>
<div flex flex-col items-center>
<div i-solar:upload-minimalistic-bold class="dark:text-primary-400 text-primary-500 mb-2 text-5xl" />
<p font-medium text="primary-600 dark:primary-300">
{{ t('settings.pages.card.drop_here') }}
</p>
</div>
</template>
</template>
</InputFile>
<!-- Card Items -->
<template v-if="cards.size > 0">
+1
View File
@@ -60,6 +60,7 @@
"@proj-airi/ccc": "workspace:^",
"@proj-airi/drizzle-duckdb-wasm": "catalog:",
"@proj-airi/server-sdk": "workspace:^",
"@vueuse/core": "^13.0.0",
"@vueuse/motion": "^3.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.2.0",
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
import InputFile from './InputFile.vue'
const files = ref<File[]>([])
</script>
<template>
<Story
title="Input File"
group="form"
:layout="{ type: 'grid', width: 600 }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant id="default" title="Default">
<InputFile v-model="files" />
<div p-2>
<div>
File List
</div>
<ul>
<li v-for="file in files" :key="file.name">
<p>
{{ file.name }}, {{ file.size }} bytes, {{ file.type }}
</p>
</li>
</ul>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useDebounce } from '@vueuse/core'
import { ref } from 'vue'
defineProps<{
accept?: string
multiple?: boolean
}>()
const files = defineModel<File[]>({ required: false, default: () => [] })
const firstFile = ref<File>()
const isDragging = ref(false)
const isDraggingDebounced = useDebounce(isDragging, 150)
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
firstFile.value = input.files[0]
}
files.value = Array.from(input.files || [])
isDragging.value = false
}
</script>
<template>
<label
class="min-h-[120px] flex flex-col cursor-pointer items-center justify-center rounded-xl p-6"
:class="[
isDraggingDebounced ? 'border-primary-400 dark:border-primary-600 hover:border-primary-300 dark:hover:border-primary-700' : 'border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-700',
isDraggingDebounced ? 'bg-primary-50/5 dark:bg-primary-900/5' : 'bg-white/60 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/40',
]"
border="dashed 2"
transition="all duration-300"
:style="{ transform: 'scale(0.98)', opacity: 0.95 }"
hover="scale-100 opacity-100 shadow-md dark:shadow-lg"
@dragover="isDragging = true"
@dragleave="isDragging = false"
>
<input
type="file"
:accept="accept"
:multiple="multiple"
class="absolute inset-0 h-full w-full opacity-0"
@change="handleFileChange"
>
<slot :is-dragging="isDraggingDebounced" :first-file="firstFile" :files="files">
<div
class="flex flex-col items-center"
:class="[
isDraggingDebounced ? 'text-primary-500 dark:text-primary-400' : 'text-neutral-400 dark:text-neutral-500',
]"
>
<div i-solar:upload-square-line-duotone mb-2 text-5xl />
<p font-medium text="center lg">
Upload
</p>
<p v-if="isDraggingDebounced" text="center" text-sm>
Release to upload
</p>
<p v-else text="center" text-sm>
Click or drag and drop a file here
</p>
</div>
</slot>
</label>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue'
import InputKeyValue from './InputKeyValue.vue'
const key = ref('')
const value = ref('')
</script>
<template>
<Story
title="Input Key Value"
group="form"
:layout="{ type: 'grid', width: 600 }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<InputKeyValue
v-model:property-key="key"
v-model:property-value="value"
key-placeholder="Key"
value-placeholder="Value"
/>
</Story>
</template>
@@ -1,2 +1,3 @@
export { default as Input } from './Input.vue'
export { default as InputFile } from './InputFile.vue'
export { default as InputKeyValue } from './InputKeyValue.vue'
+3
View File
@@ -1063,6 +1063,9 @@ importers:
'@proj-airi/server-sdk':
specifier: workspace:^
version: link:../server-sdk
'@vueuse/core':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.3))
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@4.39.0)(vue@3.5.13(typescript@5.8.3))