Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
import { useEffect } from 'react' | |
import { ClapEntity, ClapSegmentCategory, newEntity } from '@aitube/clap' | |
import { useTimeline } from '@aitube/timeline' | |
import { FormFile } from '@/components/forms/FormFile' | |
import { FormInput } from '@/components/forms/FormInput' | |
import { FormSection } from '@/components/forms/FormSection' | |
import { FormSelect } from '@/components/forms/FormSelect' | |
import { Button } from '@/components/ui/button' | |
import { useEntityEditor, useIO } from '@/services' | |
function EntityList({ | |
onSelectEntity, | |
}: { | |
onSelectEntity: (entityId: string) => void | |
}) { | |
const entities = useTimeline((s) => s.entities) | |
const setCurrent = useEntityEditor((s) => s.setCurrent) | |
const addEntity = useEntityEditor((s) => s.addEntity) | |
const removeEntity = useEntityEditor((s) => s.removeEntity) | |
const handleAddEntity = () => { | |
const entity: ClapEntity = newEntity({ | |
id: Date.now().toString(), | |
label: 'NEW_ENTITY', | |
category: ClapSegmentCategory.CHARACTER, | |
description: '', | |
appearance: '', | |
}) // ignoring some fields for now | |
addEntity(entity) | |
} | |
return ( | |
<div className="pt-4"> | |
<div className="mb-2"> | |
<h1 className="mb-4 inline px-4 text-xl font-bold">Entities</h1> | |
<Button | |
onClick={handleAddEntity} | |
className="absolute right-2 top-2" | |
variant="secondary" | |
> | |
New + | |
</Button> | |
</div> | |
<ul> | |
{entities.map((entity: ClapEntity) => ( | |
<li key={entity.id} className={`flex px-2 py-1`}> | |
<Button | |
onClick={() => { | |
setCurrent(entity) | |
onSelectEntity(entity.id) | |
}} | |
variant="ghost" | |
> | |
{entity.label} ({entity.category}) | |
</Button> | |
<Button | |
onClick={() => removeEntity(entity.id)} | |
className={`ml-2 ml-auto`} | |
variant="destructive" | |
size="sm" | |
> | |
Remove | |
</Button> | |
</li> | |
))} | |
</ul> | |
</div> | |
) | |
} | |
export function EntityEditor() { | |
const entities = useTimeline((s) => s.entities) | |
const updateEntities = useTimeline((s) => s.updateEntities) | |
const saveEntitiesToClap = useIO((s) => s.saveEntitiesToClap) | |
const openEntitiesFromClap = useIO((s) => s.openEntitiesFromClap) | |
const current = useEntityEditor((s) => s.current) | |
const setCurrent = useEntityEditor((s) => s.setCurrent) | |
const draft = useEntityEditor((s) => s.draft) | |
const setDraft = useEntityEditor((s) => s.setDraft) | |
const showEntityList = useEntityEditor((s) => s.showEntityList) | |
const setShowEntityList = useEntityEditor((s) => s.setShowEntityList) | |
useEffect(() => { | |
setCurrent(entities.at(0)) | |
}, [entities, setCurrent]) | |
useEffect(() => { | |
setDraft(current) | |
}, [current, setDraft]) | |
const handleInputChange = ( | |
field: keyof ClapEntity, | |
value: string | number | undefined | |
) => { | |
if (!draft) { | |
return | |
} | |
let updatedValue = value | |
if (field === 'age') { | |
updatedValue = value === '' ? undefined : parseInt(value as string) | |
} | |
if (field === 'label') { | |
updatedValue = value?.toString().toUpperCase() | |
} | |
setDraft({ ...draft, [field]: updatedValue }) | |
} | |
const handleSave = () => { | |
if (!draft) { | |
return | |
} | |
updateEntities([draft]) | |
} | |
const handleFileUpload = async (field: 'imageId' | 'audioId', file: File) => { | |
if (!draft) { | |
return | |
} | |
const dataUrl = await new Promise<string>((resolve) => { | |
const reader = new FileReader() | |
reader.onload = (e) => resolve(e.target?.result as string) | |
reader.readAsDataURL(file) | |
}) | |
setDraft({ ...draft, [field]: dataUrl }) | |
} | |
const handleExport = async () => { | |
if (!draft) { | |
return | |
} | |
await saveEntitiesToClap([draft]) | |
} | |
const handleImport = async (file: File) => { | |
await openEntitiesFromClap(file) | |
} | |
const handleBack = () => { | |
setShowEntityList(true) | |
} | |
const handleSelectEntity = (entityId: string) => { | |
setShowEntityList(false) | |
} | |
return ( | |
<div className="flex h-full w-full flex-col overflow-x-auto"> | |
{showEntityList ? ( | |
<div className="mb-4"> | |
<EntityList onSelectEntity={handleSelectEntity} /> | |
</div> | |
) : ( | |
<div className="flex"> | |
{draft && ( | |
<FormSection className="px-2"> | |
<Button onClick={handleBack}>Back</Button> | |
<FormInput | |
label="Identifier (UPPERCASE)" | |
value={draft.label || ''} | |
onChange={(value) => handleInputChange('label', value)} | |
/> | |
<FormSelect<ClapSegmentCategory> | |
label="Category" | |
selectedItemId={draft.category} | |
items={Object.values(ClapSegmentCategory).map( | |
(category: ClapSegmentCategory) => ({ | |
id: category, | |
label: category, | |
value: category, | |
}) | |
)} | |
onSelect={(value) => handleInputChange('category', value)} | |
/> | |
{/* ... form fields ... */} | |
<FormFile | |
label="Visual Identity" | |
onChange={(files) => | |
files[0] && handleFileUpload('imageId', files[0]) | |
} | |
/> | |
{draft.imageId && ( | |
<div className="mt-2"> | |
<img | |
src={draft.imageId} | |
alt="Entity Preview" | |
className="h-auto max-w-full" | |
/> | |
</div> | |
)} | |
<FormFile | |
label="Audio Identity" | |
onChange={(files) => | |
files[0] && handleFileUpload('audioId', files[0]) | |
} | |
/> | |
{draft.audioId && ( | |
<div className="mt-2"> | |
<audio controls src={draft.audioId} /> | |
</div> | |
)} | |
<FormInput | |
label="Description" | |
value={draft.description || ''} | |
onChange={(value) => handleInputChange('description', value)} | |
/> | |
<FormInput | |
label="Appearance" | |
value={draft.appearance || ''} | |
onChange={(value) => handleInputChange('appearance', value)} | |
/> | |
<FormInput | |
label="Age" | |
value={draft.age?.toString() || ''} | |
onChange={(value) => handleInputChange('age', value)} | |
/> | |
<FormInput | |
label="Gender" | |
value={draft.gender || ''} | |
onChange={(value) => handleInputChange('gender', value)} | |
/> | |
<div className="mt-4 flex space-x-2"> | |
<Button onClick={handleSave}>Save</Button> | |
<Button onClick={handleExport}>Export</Button> | |
</div> | |
<div className="mt-4 flex space-x-2"> | |
<FormFile | |
label="Import" | |
onChange={(files) => files[0] && handleImport(files[0])} | |
/> | |
</div> | |
</FormSection> | |
)} | |
</div> | |
)} | |
</div> | |
) | |
} | |