Redesign note display as message cards; fix reply and Textarea v-model bugs

- NoteItem.vue: render each note as a rounded card (own notes tinted)
  instead of a flat list row.
- TaskNotes.vue: show a "Replying to X" banner with cancel and
  auto-focus the composer when Reply is clicked, so replying is
  actually visible instead of silently setting hidden state.
- ShotDetailPanel/ShotBrowser/TaskDetailPanel: carry the target note
  id through select-task so replying from the shot-level aggregated
  notes view pre-fills the reply in the destination task's panel
  instead of just switching tabs.
- Textarea.vue: fix v-model, which was never wired up (modelValue/
  update:modelValue fell through as dead attrs on the native
  textarea), so typed content never reached the bound ref anywhere
  it's used - notes, submissions, and shot/asset/episode/project
  description fields.
- AssetDetailPanel/ShotDetailPanel/TaskDetailPanel: drop the header
  status badge and switch tab labels to icons (with an unread-style
  badge on Notes), fixing the TabsTrigger clipping/vertical alignment
  it introduced.
This commit is contained in:
2026-07-19 00:00:11 +08:00
parent 4bffec642e
commit 762bd34f74
8 changed files with 262 additions and 133 deletions
@@ -13,13 +13,6 @@
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
@@ -30,16 +23,26 @@
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="references">
References
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ references.length }}
</Badge>
</TabsTrigger>
@@ -267,7 +270,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Plus, MessageSquarePlus, Paperclip, Send
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
+5 -1
View File
@@ -170,6 +170,7 @@
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
:initial-reply-note-id="selectedTaskReplyNoteId"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
@@ -848,16 +849,19 @@ const clearSearch = () => {
const selectedTaskId = ref<number | null>(null)
const selectedTaskTab = ref<string>('infos')
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
const handleSelectTask = (task: { id: number }, tab?: string) => {
const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
selectedTaskId.value = task.id
selectedTaskTab.value = tab || 'infos'
selectedTaskReplyNoteId.value = noteId
}
// Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => {
selectedTaskId.value = null
selectedTaskTab.value = 'infos'
selectedTaskReplyNoteId.value = undefined
})
const formatStatus = (status: ShotStatus) => {
+109 -57
View File
@@ -13,13 +13,6 @@
<div v-else-if="shot" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
@@ -30,11 +23,34 @@
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="assets">Assets</TabsTrigger>
<TabsTrigger value="references">References</TabsTrigger>
<TabsTrigger value="design">Design</TabsTrigger>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="shotNotes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="assets" title="Assets">
<Package class="h-4 w-4" />
<span class="sr-only">Assets</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
</TabsTrigger>
<TabsTrigger value="design" title="Design">
<Edit class="h-4 w-4" />
<span class="sr-only">Design</span>
</TabsTrigger>
</TabsList>
<!-- Infos Tab -->
@@ -229,7 +245,35 @@
</Popover>
</div>
<div class="text-center py-8">
<Select v-if="tasks.length > 0" v-model="noteTaskFilter">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tasks</SelectItem>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes...
</div>
<div v-else-if="filteredShotNotes.length > 0" class="space-y-4">
<div v-for="note in filteredShotNotes" :key="note.id" class="space-y-1">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeForNote(note) || '') }}</Badge>
<NoteItem
:note="note"
:task-id="note.task_id"
@note-updated="handleNoteUpdated"
@reply="handleNoteReply"
/>
</div>
</div>
<div v-else class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
@@ -334,21 +378,23 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import NoteItem from '@/components/task/NoteItem.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
@@ -375,7 +421,7 @@ interface Props {
interface Emits {
(e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
(e: 'link-asset'): void
(e: 'edit-design'): void
(e: 'close'): void
@@ -395,6 +441,9 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const isLoadingNotes = ref(false)
const noteTaskFilter = ref<number | 'all'>('all')
// Computed properties
const frameCount = computed(() => {
@@ -436,6 +485,17 @@ const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const filteredShotNotes = computed(() => {
const notes = noteTaskFilter.value === 'all'
? shotNotes.value
: shotNotes.value.filter(note => note.task_id === noteTaskFilter.value)
return [...notes].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
})
const taskTypeForNote = (note: ProductionNote) => {
return tasks.value.find(task => task.id === note.task_id)?.task_type
}
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => {
@@ -460,6 +520,7 @@ const loadShotDetails = async () => {
loadProjectMembers()
])
loadTasks() // No longer async - uses embedded data
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err)
@@ -468,6 +529,36 @@ const loadShotDetails = async () => {
}
}
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
return
}
try {
isLoadingNotes.value = true
const notesByTask = await Promise.all(
tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))
)
shotNotes.value = notesByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
const handleNoteUpdated = () => {
loadShotNotes()
}
const handleNoteReply = (noteId: number) => {
const note = shotNotes.value.find(n => n.id === noteId)
const task = note ? tasks.value.find(t => t.id === note.task_id) : undefined
if (task) {
emit('select-task', task, 'notes', noteId)
}
}
const loadProjectMembers = async () => {
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
@@ -519,12 +610,6 @@ const handleAddTask = async (taskType: string) => {
}
}
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
@@ -537,40 +622,6 @@ const getTaskAssigneeInitials = (task: Task) => {
return (first + last).toUpperCase()
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -596,6 +647,7 @@ const formatDeletedDate = (deletedAt: string) => {
// Watchers
watch(() => props.shotId, (newShotId) => {
if (newShotId) {
noteTaskFilter.value = 'all'
loadShotDetails()
}
}, { immediate: true })
+33 -29
View File
@@ -19,40 +19,44 @@
</div>
<!-- Content -->
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatDateTime(note.created_at) }}
</span>
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
(edited)
</span>
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
</div>
<div class="flex-1 min-w-0 space-y-1">
<!-- Message Card -->
<div
class="rounded-2xl border px-3 py-2"
:class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
<span class="text-xs text-muted-foreground ml-auto">
{{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
</span>
</div>
<!-- Note Content -->
<div v-if="!editing" class="text-sm whitespace-pre-wrap">
{{ note.content }}
</div>
<!-- Note Content -->
<div v-if="!editing" class="text-sm whitespace-pre-wrap mt-0.5">
{{ note.content }}
</div>
<!-- Edit Form -->
<div v-else class="space-y-2">
<Textarea
v-model="editContent"
rows="3"
class="resize-none"
/>
<div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
<!-- Edit Form -->
<div v-else class="space-y-2 mt-1">
<Textarea
v-model="editContent"
rows="3"
class="resize-none bg-background"
/>
<div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div>
</div>
<!-- Actions -->
<div v-if="!editing" class="flex gap-2">
<div v-if="!editing" class="flex gap-2 px-1">
<Button
variant="ghost"
size="sm"
@@ -82,7 +86,7 @@
</div>
<!-- Child Notes (Threaded) -->
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-4 space-y-4 pl-4 border-l-2">
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-3 space-y-3 pl-4 border-l-2">
<NoteItem
v-for="childNote in note.child_notes"
:key="childNote.id"
@@ -12,31 +12,39 @@
<!-- Task Details -->
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
<template #badges>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
<!-- Tabs List (Fixed) -->
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="attachments">
Attachments
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="attachments" title="Attachments">
<Paperclip class="h-4 w-4" />
<span class="sr-only">Attachments</span>
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ attachments.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="submissions">
Submissions
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="submissions" title="Submissions">
<Upload class="h-4 w-4" />
<span class="sr-only">Submissions</span>
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ submissions.length }}
</Badge>
</TabsTrigger>
@@ -178,7 +186,12 @@
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" />
<TaskNotes
:task-id="taskId"
:notes="notes"
:initial-reply-note-id="initialReplyNoteId"
@notes-updated="loadNotes"
/>
</TabsContent>
<!-- Attachments Tab -->
@@ -262,7 +275,7 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
import { Play, Upload, UserPlus, Calendar, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
@@ -299,7 +312,6 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import TaskStatusBadge from './TaskStatusBadge.vue'
import TaskNotes from './TaskNotes.vue'
import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue'
@@ -311,6 +323,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
initialTab?: string
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
+50 -9
View File
@@ -20,12 +20,23 @@
<!-- Note Input (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
@@ -60,8 +71,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { MessageSquarePlus } from 'lucide-vue-next'
import { ref, computed, nextTick, onMounted } from 'vue'
import { MessageSquarePlus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import NoteItem from './NoteItem.vue'
@@ -71,6 +82,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
notes: ProductionNote[]
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
@@ -83,6 +95,23 @@ const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
async function handleAddNote() {
if (!newNoteContent.value.trim()) return
@@ -117,6 +146,18 @@ async function handleAddNote() {
function handleReply(noteId: number) {
replyToNoteId.value = noteId
// Focus on textarea (you could add a ref for this)
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
onMounted(() => {
if (props.initialReplyNoteId) {
handleReply(props.initialReplyNoteId)
}
})
</script>
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
props.class,
)"
>
<span class="truncate">
<span class="inline-flex items-center justify-center">
<slot />
</span>
</TabsTrigger>
@@ -1,21 +1,33 @@
<template>
<textarea
v-model="modelValue"
:class="cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
interface Props {
class?: string
modelValue?: string
defaultValue?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const emits = defineEmits<{
(e: 'update:modelValue', payload: string): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>