feat(stage-ui): input file component
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user