refactor(airi-card): dialog (#111)
* refactor(airi-card): dialog * feat: CardListItem * style: IconStatusItem
This commit is contained in:
@@ -73,30 +73,42 @@ settings:
|
||||
models: Model
|
||||
pages:
|
||||
card:
|
||||
activate: Activate
|
||||
title: Airi Card
|
||||
description: Use Airi character card presets
|
||||
upload: Upload
|
||||
upload_desc: Click or drag file to upload
|
||||
drop_here: Drop to upload
|
||||
delete: Delete
|
||||
active: Active
|
||||
active_badge: Currently Active
|
||||
activate: Activate
|
||||
cancel: Cancel
|
||||
card_not_found: Card not found
|
||||
character: Character
|
||||
personality: Personality
|
||||
scenario: Scenario
|
||||
systemprompt: System Prompt
|
||||
posthistoryinstructions: Post-History Instructions
|
||||
modules: Modules
|
||||
close: Close
|
||||
search: Search cards...
|
||||
sort_by: Sort by
|
||||
name_asc: Name (A-Z)
|
||||
name_desc: Name (Z-A)
|
||||
recent: Recently Added
|
||||
no_cards: No cards yet. Click the button above to upload one!
|
||||
no_results: No matching cards found
|
||||
try_different_search: Try a different search term
|
||||
consciousness:
|
||||
model: Consciousness / Model
|
||||
created_by: created by
|
||||
creator_notes: Creator Notes
|
||||
delete: Delete
|
||||
delete_card: Delete Card
|
||||
delete_confirmation: Are you sure you want to delete this card?
|
||||
description: Use Airi character card presets
|
||||
description_label: Description
|
||||
modules: Modules
|
||||
personality: Personality
|
||||
posthistoryinstructions: Post-History Instructions
|
||||
scenario: Scenario
|
||||
speech:
|
||||
model: Speech / Model
|
||||
voice: Speech / Voice
|
||||
systemprompt: System Prompt
|
||||
title: Airi Card
|
||||
upload: Upload
|
||||
memory:
|
||||
description: Where memories got stored, and organized
|
||||
title: Memory
|
||||
|
||||
@@ -60,8 +60,11 @@ settings:
|
||||
title: Airi 角色卡
|
||||
description: 使用 Airi 角色卡预设
|
||||
upload: 上传
|
||||
upload_desc: 点击或拖拽文件到此处上传
|
||||
drop_here: 放开以上传文件
|
||||
delete: 删除
|
||||
active: 已激活
|
||||
active_badge: 当前使用中
|
||||
activate: 激活
|
||||
delete_card: 删除角色卡
|
||||
delete_confirmation: 确定要删除这张角色卡吗?
|
||||
@@ -76,6 +79,15 @@ settings:
|
||||
posthistoryinstructions: 历史提示指令
|
||||
modules: 模块
|
||||
cancel: 取消
|
||||
close: 关闭
|
||||
search: 搜索角色卡...
|
||||
sort_by: 排序方式
|
||||
name_asc: 名称 (A-Z)
|
||||
name_desc: 名称 (Z-A)
|
||||
recent: 最近添加
|
||||
no_cards: 还没有任何角色卡,点击上方按钮上传一个吧!
|
||||
no_results: 没有找到匹配的角色卡
|
||||
try_different_search: 尝试使用其他关键词搜索
|
||||
consciousness:
|
||||
model: 意识 / 模型
|
||||
speech:
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiriCard } from '@proj-airi/stage-ui/stores'
|
||||
|
||||
import { Button, Section } from '@proj-airi/stage-ui/components'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from 'radix-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute('/settings/airi-card/[id]')
|
||||
const { t } = useI18n()
|
||||
|
||||
const cardStore = useAiriCardStore()
|
||||
const { getCard, removeCard } = cardStore
|
||||
const { activeCardId } = storeToRefs(cardStore)
|
||||
|
||||
const id = computed(() => route.params.id)
|
||||
|
||||
// Get current card
|
||||
const card = computed<AiriCard | undefined>(() => getCard(id.value as string))
|
||||
|
||||
// Get module settings
|
||||
const moduleSettings = computed(() => {
|
||||
const airiExt = card.value?.extensions?.airi?.modules
|
||||
return {
|
||||
consciousness: airiExt?.consciousness?.model || '',
|
||||
speech: airiExt?.speech?.model || '',
|
||||
voice: airiExt?.speech?.voice_id || '',
|
||||
}
|
||||
})
|
||||
|
||||
// Get character settings
|
||||
const characterSettings = computed(() => {
|
||||
if (!card.value)
|
||||
return {}
|
||||
|
||||
return {
|
||||
personality: card.value.personality,
|
||||
scenario: card.value.scenario,
|
||||
systemPrompt: card.value.systemPrompt,
|
||||
postHistoryInstructions: card.value.postHistoryInstructions,
|
||||
}
|
||||
})
|
||||
|
||||
// Check if card is active
|
||||
const isActive = computed(() => id.value === activeCardId.value)
|
||||
|
||||
// Activate card
|
||||
function activateCard() {
|
||||
activeCardId.value = id.value as string
|
||||
}
|
||||
|
||||
// Animation control for card activation
|
||||
const isActivating = ref(false)
|
||||
|
||||
function handleActivate() {
|
||||
isActivating.value = true
|
||||
setTimeout(() => {
|
||||
activateCard()
|
||||
isActivating.value = false
|
||||
activeCardId.value = 'default'
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Delete card confirmation
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
if (card.value) {
|
||||
removeCard(id.value as string)
|
||||
activateCard()
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
|
||||
function hightlightTagToHtml(text: string) {
|
||||
return text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-motion
|
||||
flex="~ row" items-center gap-2
|
||||
:initial="{ opacity: 0, x: 10 }"
|
||||
:enter="{ opacity: 1, x: 0 }"
|
||||
:leave="{ opacity: 0, x: -10 }"
|
||||
:duration="250"
|
||||
>
|
||||
<button @click="router.back()">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
<h1 relative>
|
||||
<div absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
|
||||
</div>
|
||||
<div text-nowrap text-3xl font-semibold>
|
||||
{{ t('settings.pages.card.title') }}
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
|
||||
<div
|
||||
v-if="card"
|
||||
bg="neutral-50 dark:[rgba(0,0,0,0.3)]"
|
||||
rounded-xl p-5 flex="~ col gap-5"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
shadow="sm dark:md"
|
||||
transition="all duration-300"
|
||||
class="backdrop-blur-sm"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div flex="~ col" gap-3>
|
||||
<div flex="~ row" items-center justify-between>
|
||||
<div>
|
||||
<h1 text-2xl font-bold class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
|
||||
{{ card.name }}
|
||||
</h1>
|
||||
<div mt-1 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
v{{ card.version }}
|
||||
<template v-if="card.creator">
|
||||
· {{ t('settings.pages.card.created_by') }} <span font-medium>{{ card.creator }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div flex="~ row" gap-2>
|
||||
<!-- Delete button -->
|
||||
<AlertDialogRoot v-if="id !== 'default'" v-model:open="showDeleteConfirm">
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button
|
||||
variant="danger"
|
||||
:label="t('settings.pages.card.delete')"
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 max-w-md w-full border border-neutral-200 rounded-xl bg-white p-6 shadow-xl -translate-x-1/2 -translate-y-1/2 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<AlertDialogTitle class="mb-4 text-xl font-bold">
|
||||
{{ t('settings.pages.card.delete_card') }}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mb-6">
|
||||
{{ t('settings.pages.card.delete_confirmation') }} <b>"{{ card.name }}"</b>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div class="flex flex-row justify-end gap-3">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:label="t('settings.pages.card.cancel')"
|
||||
@click="() => showDeleteConfirm = false"
|
||||
/>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button
|
||||
variant="danger"
|
||||
:label="t('settings.pages.card.delete')"
|
||||
@click="handleDeleteConfirm"
|
||||
/>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<!-- Activation button -->
|
||||
<Button
|
||||
variant="primary"
|
||||
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
|
||||
:disabled="isActive"
|
||||
:class="{ 'animate-pulse': isActivating }"
|
||||
@click="handleActivate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator notes -->
|
||||
<Section v-if="card.notes" :title="t('settings.pages.card.creator_notes')" icon="i-solar:notes-linear">
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
max-h-80 overflow-auto whitespace-pre-line rounded-lg p-4 text-neutral-700 dark:text-neutral-300 transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
v-html="hightlightTagToHtml(card.notes)"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<!-- Description section -->
|
||||
<Section v-if="card.description" :title="t('settings.pages.card.description_label')" icon="i-solar:document-text-linear">
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
max-h-80 overflow-auto whitespace-pre-line rounded-lg p-4
|
||||
text="neutral-600 dark:neutral-300"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
v-html="hightlightTagToHtml(card.description)"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<!-- Character -->
|
||||
<template v-if="Object.values(characterSettings).some(value => !!value)">
|
||||
<Section :title="t('settings.pages.card.character')" icon="i-solar:user-rounded-linear">
|
||||
<div flex="~ col" gap-4>
|
||||
<template v-for="(value, key) in characterSettings" :key="key">
|
||||
<div v-if="value" flex="~ col" gap-2>
|
||||
<h2 text-lg text-neutral-500 font-medium dark:text-neutral-400>
|
||||
{{ t(`settings.pages.card.${key.toLowerCase()}`) }}
|
||||
</h2>
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
max-h-80 overflow-auto whitespace-pre-line rounded-lg p-3 text-neutral-700 dark:text-neutral-300
|
||||
v-html="hightlightTagToHtml(value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<!-- Modules -->
|
||||
<Section :title="t('settings.pages.card.modules')" icon="i-solar:tuning-square-linear">
|
||||
<div grid="~ cols-1 sm:cols-3" gap-4>
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-1 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:ghost />
|
||||
{{ t('settings.pages.card.consciousness.model') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.consciousness ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-2 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:mic />
|
||||
{{ t('settings.pages.card.speech.model') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.speech ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-2 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:music />
|
||||
{{ t('settings.pages.card.speech.voice') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.voice ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
bg="neutral-50/50 dark:neutral-900/50"
|
||||
rounded-xl p-8 text-center
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
shadow="sm"
|
||||
>
|
||||
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
|
||||
{{ t('settings.pages.card.card_not_found') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
|
||||
<div text="40" i-lucide:id-card />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiriCard } from '@proj-airi/stage-ui/stores'
|
||||
|
||||
import { Button } from '@proj-airi/stage-ui/components'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'radix-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DeleteCardDialog from './DeleteCardDialog.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
cardId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const cardStore = useAiriCardStore()
|
||||
const { removeCard } = cardStore
|
||||
const { activeCardId } = storeToRefs(cardStore)
|
||||
|
||||
// Get selected card data
|
||||
const selectedCard = computed<AiriCard | undefined>(() => {
|
||||
if (!props.cardId)
|
||||
return undefined
|
||||
return cardStore.getCard(props.cardId)
|
||||
})
|
||||
|
||||
// Get module settings
|
||||
const moduleSettings = computed(() => {
|
||||
if (!selectedCard.value || !selectedCard.value.extensions?.airi?.modules) {
|
||||
return {
|
||||
consciousness: '',
|
||||
speech: '',
|
||||
voice: '',
|
||||
}
|
||||
}
|
||||
|
||||
const airiExt = selectedCard.value.extensions.airi.modules
|
||||
return {
|
||||
consciousness: airiExt.consciousness?.model || '',
|
||||
speech: airiExt.speech?.model || '',
|
||||
voice: airiExt.speech?.voice_id || '',
|
||||
}
|
||||
})
|
||||
|
||||
// Get character settings
|
||||
const characterSettings = computed(() => {
|
||||
if (!selectedCard.value)
|
||||
return {}
|
||||
|
||||
return {
|
||||
personality: selectedCard.value.personality,
|
||||
scenario: selectedCard.value.scenario,
|
||||
systemPrompt: selectedCard.value.systemPrompt,
|
||||
postHistoryInstructions: selectedCard.value.postHistoryInstructions,
|
||||
}
|
||||
})
|
||||
|
||||
// Check if card is active
|
||||
const isActive = computed(() => props.cardId === activeCardId.value)
|
||||
|
||||
// Animation control for card activation
|
||||
const isActivating = ref(false)
|
||||
|
||||
function handleActivate() {
|
||||
isActivating.value = true
|
||||
setTimeout(() => {
|
||||
activeCardId.value = props.cardId
|
||||
isActivating.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function hightlightTagToHtml(text: string) {
|
||||
return text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim()
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
if (selectedCard.value) {
|
||||
removeCard(props.cardId)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
|
||||
// Tab type definition
|
||||
interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// Active tab ID state
|
||||
const activeTabId = ref('')
|
||||
|
||||
// Tabs for card details
|
||||
const tabs = computed<Tab[]>(() => {
|
||||
const availableTabs: Tab[] = []
|
||||
|
||||
// Description tab - always show if there's description
|
||||
if (selectedCard.value?.description) {
|
||||
availableTabs.push({
|
||||
id: 'description',
|
||||
label: t('settings.pages.card.description_label'),
|
||||
icon: 'i-solar:document-text-linear',
|
||||
})
|
||||
}
|
||||
|
||||
// Notes tab - only show if there are creator notes
|
||||
if (selectedCard.value?.notes) {
|
||||
availableTabs.push({
|
||||
id: 'notes',
|
||||
label: t('settings.pages.card.creator_notes'),
|
||||
icon: 'i-solar:notes-linear',
|
||||
})
|
||||
}
|
||||
|
||||
// Character tab - only show if there are character settings
|
||||
if (Object.values(characterSettings.value).some(value => !!value)) {
|
||||
availableTabs.push({
|
||||
id: 'character',
|
||||
label: t('settings.pages.card.character'),
|
||||
icon: 'i-solar:user-rounded-linear',
|
||||
})
|
||||
}
|
||||
|
||||
// Modules tab - always show
|
||||
availableTabs.push({
|
||||
id: 'modules',
|
||||
label: t('settings.pages.card.modules'),
|
||||
icon: 'i-solar:tuning-square-linear',
|
||||
})
|
||||
|
||||
return availableTabs
|
||||
})
|
||||
|
||||
// Active tab state - set to first available tab by default
|
||||
const activeTab = computed({
|
||||
get: () => {
|
||||
// If current active tab is not in available tabs, reset to first tab
|
||||
if (!tabs.value.find(tab => tab.id === activeTabId.value))
|
||||
return tabs.value[0]?.id || ''
|
||||
return activeTabId.value
|
||||
},
|
||||
set: (value: string) => {
|
||||
activeTabId.value = value
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" />
|
||||
<DialogContent class="data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide fixed left-1/2 top-1/2 z-50 m-0 max-h-[90vh] max-w-6xl w-[92vw] flex flex-col overflow-auto border border-neutral-200 rounded-xl bg-white p-5 shadow-xl 2xl:w-[60vw] lg:w-[80vw] md:w-[85vw] xl:w-[70vw] -translate-x-1/2 -translate-y-1/2 dark:border-neutral-700 dark:bg-neutral-800 sm:p-6">
|
||||
<div v-if="selectedCard" class="w-full flex flex-col gap-5">
|
||||
<!-- Header with status indicator -->
|
||||
<div flex="~ col" gap-3>
|
||||
<div flex="~ row" items-center justify-between>
|
||||
<div>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
<DialogTitle text-2xl font-bold class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
|
||||
{{ selectedCard.name }}
|
||||
</DialogTitle>
|
||||
<div v-if="isActive" class="bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-400 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
<div i-solar:check-circle-bold-duotone text-xs />
|
||||
{{ t('settings.pages.card.active_badge') }}
|
||||
</div>
|
||||
</div>
|
||||
<div mt-1 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
v{{ selectedCard.version }}
|
||||
<template v-if="selectedCard.creator">
|
||||
· {{ t('settings.pages.card.created_by') }} <span font-medium>{{ selectedCard.creator }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div flex="~ row" gap-2>
|
||||
<!-- Activation button -->
|
||||
<Button
|
||||
variant="primary"
|
||||
:icon="isActive ? 'i-solar:check-circle-bold-duotone' : 'i-solar:play-circle-broken'"
|
||||
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
|
||||
:disabled="isActive"
|
||||
:class="{ 'animate-pulse': isActivating }"
|
||||
@click="handleActivate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content tabs -->
|
||||
<div class="mt-4">
|
||||
<div class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div class="flex justify-center -mb-px sm:justify-start space-x-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="px-4 py-2 text-sm font-medium"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300',
|
||||
]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div :class="tab.icon" />
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator notes -->
|
||||
<div v-if="activeTab === 'notes' && selectedCard.notes">
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 text-neutral-700 sm:max-h-80 dark:text-neutral-300 transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
v-html="hightlightTagToHtml(selectedCard.notes)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description section -->
|
||||
<div v-if="activeTab === 'description' && selectedCard.description">
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-4 sm:max-h-80
|
||||
text="neutral-600 dark:neutral-300"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
v-html="hightlightTagToHtml(selectedCard.description)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Character -->
|
||||
<div v-if="activeTab === 'character' && Object.values(characterSettings).some(value => !!value)">
|
||||
<div flex="~ col" max-h-60 gap-4 overflow-auto pr-1 sm:max-h-80>
|
||||
<template v-for="(value, key) in characterSettings" :key="key">
|
||||
<div v-if="value" flex="~ col" gap-2>
|
||||
<h2 text-lg text-neutral-500 font-medium dark:text-neutral-400>
|
||||
{{ t(`settings.pages.card.${key.toLowerCase()}`) }}
|
||||
</h2>
|
||||
<div
|
||||
bg="white/60 dark:black/30"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
max-h-none overflow-auto whitespace-pre-line rounded-lg p-3 text-neutral-700 dark:text-neutral-300
|
||||
v-html="hightlightTagToHtml(value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modules -->
|
||||
<div v-if="activeTab === 'modules'">
|
||||
<div grid="~ cols-1 sm:cols-3" gap-4>
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-1 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:ghost />
|
||||
{{ t('settings.pages.card.consciousness.model') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.consciousness ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-2 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:mic />
|
||||
{{ t('settings.pages.card.speech.model') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.speech ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ col"
|
||||
bg="white/60 dark:black/30"
|
||||
gap-2 rounded-lg p-3
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
transition="all duration-200"
|
||||
hover="bg-white/80 dark:bg-black/40"
|
||||
>
|
||||
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
<div i-lucide:music />
|
||||
{{ t('settings.pages.card.speech.voice') }}
|
||||
</span>
|
||||
<div truncate font-medium>
|
||||
{{ moduleSettings.voice ?? 'default' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
bg="neutral-50/50 dark:neutral-900/50"
|
||||
rounded-xl p-8 text-center
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
shadow="sm"
|
||||
>
|
||||
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
|
||||
{{ t('settings.pages.card.card_not_found') }}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DeleteCardDialog
|
||||
v-model="showDeleteConfirm"
|
||||
:card-name="selectedCard?.name"
|
||||
@confirm="handleDeleteConfirm"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
isActive: boolean
|
||||
isSelected: boolean
|
||||
version: string
|
||||
consciousnessModel: string
|
||||
voiceModel: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'activate'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
relative min-h-120px flex="~ col" cursor-pointer overflow-hidden rounded-xl
|
||||
:class="[
|
||||
isSelected
|
||||
? 'border-2 border-primary-400 dark:border-primary-600'
|
||||
: 'border-2 border-neutral-100 dark:border-neutral-800/25',
|
||||
]"
|
||||
bg="neutral-200/50 dark:neutral-800/50"
|
||||
drop-shadow="none hover:[0px_4px_4px_rgba(220,220,220,0.4)] active:[0px_0px_0px_rgba(220,220,220,0.25)] dark:hover:none"
|
||||
transition="all ease-in-out duration-400"
|
||||
before="content-empty absolute inset-0 z-0 w-25% h-full transition-all duration-400 ease-in-out bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0 mask-image-[linear-gradient(120deg,white_100%)] opacity-0"
|
||||
hover="before:(w-50% opacity-100 bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent)"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<!-- Card content -->
|
||||
<div
|
||||
relative flex="~ col 1" justify-between gap-3 overflow-hidden rounded-lg bg="white dark:neutral-900" p-5
|
||||
transition="all ease-in-out duration-400"
|
||||
after="content-empty absolute inset-0 z--2 w-full h-full bg-dotted-[neutral-200/80] bg-size-10px mask-image-[linear-gradient(165deg,white_30%,transparent_50%)] transition-all duration-400 ease-in-out"
|
||||
hover="after:bg-dotted-[primary-300/50] dark:after:bg-dotted-[primary-200/20]"
|
||||
>
|
||||
<!-- Card header (name and badge) -->
|
||||
<div z-1 flex items-start justify-between gap-2>
|
||||
<h3 flex-1 truncate text-lg font-bold hover="text-primary-600 dark:text-primary-300">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div v-if="isActive" shrink-0 rounded-md p-1 bg="primary-100 dark:primary-900/40" text="primary-600 dark:primary-400">
|
||||
<div i-solar:check-circle-bold-duotone text-sm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card description -->
|
||||
<p v-if="description" line-clamp-3 min-h-40px flex-1 text-sm text="neutral-500 dark:neutral-400" hover="text-primary-600/80 dark:text-primary-300/80">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Card stats -->
|
||||
<div z-1 flex items-center justify-between text-xs text="neutral-500 dark:neutral-400">
|
||||
<div>v{{ version }}</div>
|
||||
<div flex items-center gap-1.5>
|
||||
<div flex items-center gap-0.5>
|
||||
<div i-lucide:ghost text-xs />
|
||||
<span>{{ consciousnessModel }}</span>
|
||||
</div>
|
||||
<div flex items-center gap-0.5>
|
||||
<div i-lucide:mic text-xs />
|
||||
<span>{{ voiceModel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card actions -->
|
||||
<div flex items-center justify-end px-2 py-1.5>
|
||||
<button
|
||||
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
|
||||
:disabled="isActive"
|
||||
@click.stop="emit('activate')"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
isActive
|
||||
? 'i-solar:check-circle-bold-duotone text-primary-500 dark:text-primary-400'
|
||||
: 'i-solar:play-circle-broken text-neutral-500 dark:text-neutral-400',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="id !== 'default'"
|
||||
rounded-lg p-1.5 transition-colors hover="bg-neutral-200 dark:bg-neutral-700/50"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<div i-solar:trash-bin-trash-linear text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@proj-airi/stage-ui/components/Button'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
} from 'radix-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
cardName?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false)
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('update:modelValue', false)
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot :open="modelValue" @update:open="emit('update:modelValue', $event)">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide fixed left-1/2 top-1/2 z-50 max-w-md w-full border border-neutral-200 rounded-xl bg-white p-6 shadow-xl -translate-x-1/2 -translate-y-1/2 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<AlertDialogTitle class="mb-4 text-xl font-bold">
|
||||
{{ t('settings.pages.card.delete_card') }}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mb-6">
|
||||
{{ t('settings.pages.card.delete_confirmation') }} <b>"{{ cardName || '' }}"</b>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div class="flex flex-row justify-end gap-3">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:label="t('settings.pages.card.cancel')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button
|
||||
variant="danger"
|
||||
:label="t('settings.pages.card.delete')"
|
||||
@click="handleConfirm"
|
||||
/>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -1,25 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { ccv3 } from '@proj-airi/ccc'
|
||||
|
||||
import { Button } from '@proj-airi/stage-ui/components/Button'
|
||||
import { RadioCardDetailManySelect } from '@proj-airi/stage-ui/components/Form'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import CardDetailDialog from './components/CardDetailDialog.vue'
|
||||
import CardListItem from './components/CardListItem.vue'
|
||||
import DeleteCardDialog from './components/DeleteCardDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const cardStore = useAiriCardStore()
|
||||
const { addCard } = cardStore
|
||||
const { addCard, removeCard } = cardStore
|
||||
const { cards, activeCardId } = storeToRefs(cardStore)
|
||||
|
||||
// Currently selected card ID (different from active card ID)
|
||||
const selectedCardId = ref<string>(activeCardId.value || '')
|
||||
const selectedCardId = ref<string>('')
|
||||
// Dialog state
|
||||
const isCardDialogOpen = ref(false)
|
||||
|
||||
// Search query
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Sort option
|
||||
const sortOption = ref('nameAsc')
|
||||
|
||||
// Drag and drop
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Card list data structure
|
||||
interface CardListItem {
|
||||
interface CardItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
@@ -28,7 +41,7 @@ interface CardListItem {
|
||||
}
|
||||
|
||||
// Transform cards Map to array for display
|
||||
const cardsArray = computed<CardListItem[]>(() =>
|
||||
const cardsArray = computed<CardItem[]>(() =>
|
||||
Array.from(cards.value.entries()).map(([id, card]) => ({
|
||||
id,
|
||||
name: card.name,
|
||||
@@ -36,6 +49,51 @@ const cardsArray = computed<CardListItem[]>(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
// Filtered cards based on search query
|
||||
const filteredCards = computed<CardItem[]>(() => {
|
||||
if (!searchQuery.value)
|
||||
return cardsArray.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return cardsArray.value.filter(item =>
|
||||
item.name.toLowerCase().includes(query)
|
||||
|| (item.description && item.description.toLowerCase().includes(query)),
|
||||
)
|
||||
})
|
||||
|
||||
// Sorted filtered cards based on sort option
|
||||
const sortedFilteredCards = computed<CardItem[]>(() => {
|
||||
// Create a new array to avoid mutating the source
|
||||
const sorted = [...filteredCards.value]
|
||||
|
||||
if (sortOption.value === 'nameAsc')
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name))
|
||||
else if (sortOption.value === 'nameDesc')
|
||||
return sorted.sort((a, b) => b.name.localeCompare(a.name))
|
||||
else if (sortOption.value === 'recent')
|
||||
return sorted.sort((a, b) => b.id.localeCompare(a.id))
|
||||
else
|
||||
return sorted
|
||||
})
|
||||
|
||||
// Delete confirmation
|
||||
const showDeleteConfirm = ref(false)
|
||||
const cardToDelete = ref<string | null>(null)
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
if (cardToDelete.value) {
|
||||
removeCard(cardToDelete.value)
|
||||
cardToDelete.value = null
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Card deletion confirmation
|
||||
function confirmDelete(id: string) {
|
||||
cardToDelete.value = id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles card file upload and processing
|
||||
*/
|
||||
@@ -55,6 +113,7 @@ async function handleUpload() {
|
||||
|
||||
// Add card and select it
|
||||
selectedCardId.value = addCard(cardJSON)
|
||||
isCardDialogOpen.value = true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error processing card file:', error)
|
||||
@@ -64,10 +123,39 @@ async function handleUpload() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
watch(selectedCardId, (cardId) => {
|
||||
if (cardId)
|
||||
router.push(`/settings/airi-card/${cardId}`)
|
||||
})
|
||||
function handleSelectCard(cardId: string) {
|
||||
selectedCardId.value = cardId
|
||||
isCardDialogOpen.value = true
|
||||
}
|
||||
|
||||
// Card activation
|
||||
function activateCard(id: string) {
|
||||
activeCardId.value = id
|
||||
}
|
||||
|
||||
// Card version number
|
||||
function getVersionNumber(id: string) {
|
||||
const card = cards.value.get(id)
|
||||
return card?.version || '1.0.0'
|
||||
}
|
||||
|
||||
// Card module short name
|
||||
function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
|
||||
const card = cards.value.get(id)
|
||||
if (!card || !card.extensions?.airi?.modules)
|
||||
return 'default'
|
||||
|
||||
const airiExt = card.extensions.airi.modules
|
||||
|
||||
if (module === 'consciousness') {
|
||||
return airiExt.consciousness?.model ? airiExt.consciousness.model.split('-').pop() || 'default' : 'default'
|
||||
}
|
||||
else if (module === 'voice') {
|
||||
return airiExt.speech?.voice_id || 'default'
|
||||
}
|
||||
|
||||
return 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,32 +180,145 @@ watch(selectedCardId, (cardId) => {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
|
||||
<!-- Toolbar -->
|
||||
<div flex="~ col" gap-6>
|
||||
<div flex="~ row gap-2 flex-wrap">
|
||||
<!-- Upload button -->
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="i-solar:upload-line-duotone"
|
||||
:label="t('settings.pages.card.upload')"
|
||||
@click="handleUpload"
|
||||
/>
|
||||
<div rounded-xl p-4 flex="~ col gap-4">
|
||||
<!-- Toolbar with search and filters -->
|
||||
<div flex="~ row" flex-wrap items-center justify-between gap-4>
|
||||
<!-- Search bar -->
|
||||
<div class="relative min-w-[200px] flex-1" inline-flex="~" w-full items-center>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<div i-solar:magnifer-line-duotone class="text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="w-full rounded-xl p-2.5 pl-10 text-sm outline-none"
|
||||
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
|
||||
transition="all duration-200 ease-in-out"
|
||||
bg="white dark:neutral-900"
|
||||
:placeholder="t('settings.pages.card.search')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Card selection -->
|
||||
<!-- Sort options -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div text-sm text-neutral-500 dark:text-neutral-400>
|
||||
{{ t('settings.pages.card.sort_by') }}:
|
||||
</div>
|
||||
<select
|
||||
v-model="sortOption"
|
||||
class="rounded-lg p-1.5 text-sm outline-none"
|
||||
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
|
||||
bg="white dark:neutral-900"
|
||||
>
|
||||
<option value="nameAsc">
|
||||
{{ t('settings.pages.card.name_asc') }}
|
||||
</option>
|
||||
<option value="nameDesc">
|
||||
{{ t('settings.pages.card.name_desc') }}
|
||||
</option>
|
||||
<option value="recent">
|
||||
{{ t('settings.pages.card.recent') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Masonry card layout -->
|
||||
<div
|
||||
class="mt-4"
|
||||
: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') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Items -->
|
||||
<template v-if="cards.size > 0">
|
||||
<RadioCardDetailManySelect
|
||||
v-model="selectedCardId"
|
||||
:items="cardsArray"
|
||||
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
|
||||
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
|
||||
:show-more="false"
|
||||
<CardListItem
|
||||
v-for="item in sortedFilteredCards"
|
||||
:id="item.id"
|
||||
:key="item.id"
|
||||
:name="item.name"
|
||||
:description="item.description"
|
||||
:is-active="item.id === activeCardId"
|
||||
:is-selected="item.id === selectedCardId && isCardDialogOpen"
|
||||
:version="getVersionNumber(item.id)"
|
||||
:consciousness-model="getModuleShortName(item.id, 'consciousness')"
|
||||
:voice-model="getModuleShortName(item.id, 'voice')"
|
||||
@select="handleSelectCard(item.id)"
|
||||
@activate="activateCard(item.id)"
|
||||
@delete="confirmDelete(item.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- No cards message -->
|
||||
<div
|
||||
v-if="cards.size === 0"
|
||||
class="col-span-full rounded-xl p-8 text-center"
|
||||
border="~ neutral-200/50 dark:neutral-700/30"
|
||||
bg="neutral-50/50 dark:neutral-900/50"
|
||||
>
|
||||
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
|
||||
<p>{{ t('settings.pages.card.no_cards') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No search results -->
|
||||
<div
|
||||
v-if="searchQuery && sortedFilteredCards.length === 0"
|
||||
class="col-span-full flex items-center gap-3 border-2 border-amber-200 rounded-xl bg-amber-50/80 p-4 dark:border-amber-800 dark:bg-amber-900/30"
|
||||
>
|
||||
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ t('settings.pages.card.no_results') }}</span>
|
||||
<span class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.pages.card.try_different_search') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DeleteCardDialog
|
||||
v-model="showDeleteConfirm"
|
||||
:card-name="cardToDelete ? cardStore.getCard(cardToDelete)?.name : ''"
|
||||
@confirm="handleDeleteConfirm"
|
||||
@cancel="cardToDelete = null"
|
||||
/>
|
||||
|
||||
<!-- Card detail dialog -->
|
||||
<CardDetailDialog
|
||||
v-model="isCardDialogOpen"
|
||||
:card-id="selectedCardId"
|
||||
/>
|
||||
|
||||
<!-- Background decoration -->
|
||||
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
|
||||
<div text="40" i-lucide:id-card />
|
||||
|
||||
Reference in New Issue
Block a user