FRE-592: Implement character database and relationship mapping
Add full character management system with enriched profiles (bio, traits, arcs, motivation, conflict, secrets), relationship mapping between characters with types and strength, character search/filter by role and arc type, and character statistics (scene count, dialogue, screen time). Includes database schema, tRPC router procedures, SolidJS components, API hooks, and unit tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
64
src/components/characters/CharacterCard.tsx
Normal file
64
src/components/characters/CharacterCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Component, For, Show } from 'solid-js';
|
||||
import type { Character } from '../../../../server/types/project';
|
||||
|
||||
export interface CharacterCardProps {
|
||||
character: Character;
|
||||
onSelect?: (character: Character) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
protagonist: '#4CAF50',
|
||||
antagonist: '#F44336',
|
||||
supporting: '#2196F3',
|
||||
background: '#9E9E9E',
|
||||
ensemble: '#9C27B0',
|
||||
};
|
||||
|
||||
export const CharacterCard: Component<CharacterCardProps> = (props) => {
|
||||
const handleClick = () => {
|
||||
props.onSelect?.(props.character);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class="character-card"
|
||||
classList={{ 'character-card--compact': props.compact }}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
>
|
||||
<div class="character-card__header">
|
||||
<Show when={props.character.imageUrl}>
|
||||
<img
|
||||
src={props.character.imageUrl}
|
||||
alt={props.character.name}
|
||||
class="character-card__avatar"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!props.character.imageUrl}>
|
||||
<div class="character-card__avatar character-card__avatar--placeholder">
|
||||
{props.character.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="character-card__info">
|
||||
<h3 class="character-card__name">{props.character.name}</h3>
|
||||
<span
|
||||
class="character-card__role"
|
||||
style={{ 'background-color': roleColors[props.character.role || 'supporting'] || '#9E9E9E' }}
|
||||
>
|
||||
{props.character.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!props.compact && props.character.bio}>
|
||||
<p class="character-card__bio">{props.character.bio}</p>
|
||||
</Show>
|
||||
<Show when={!props.compact && props.character.traits}>
|
||||
<div class="character-card__traits">
|
||||
<strong>Traits:</strong> {props.character.traits}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
src/components/characters/CharacterList.tsx
Normal file
173
src/components/characters/CharacterList.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Component, createSignal, For, Show } from 'solid-js';
|
||||
import {
|
||||
useCharacters,
|
||||
useCreateCharacter,
|
||||
useUpdateCharacter,
|
||||
useDeleteCharacter,
|
||||
} from '../../../lib/api/trpc-hooks';
|
||||
import { CharacterCard } from './CharacterCard';
|
||||
import { CharacterProfile } from './CharacterProfile';
|
||||
import type { Character } from '../../../../server/types/project';
|
||||
|
||||
export interface CharacterListProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const CharacterList: Component<CharacterListProps> = (props) => {
|
||||
const charactersQuery = useCharacters(props.projectId);
|
||||
const createCharacter = useCreateCharacter();
|
||||
const [selectedCharacter, setSelectedCharacter] = createSignal<Character | null>(null);
|
||||
const [showForm, setShowForm] = createSignal(false);
|
||||
|
||||
const [formData, setFormData] = createSignal({
|
||||
name: '',
|
||||
description: '',
|
||||
bio: '',
|
||||
role: 'supporting' as 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble',
|
||||
arc: '',
|
||||
arcType: undefined as 'positive' | 'negative' | 'flat' | 'complex' | undefined,
|
||||
age: undefined as number | undefined,
|
||||
gender: '',
|
||||
voice: '',
|
||||
traits: '',
|
||||
motivation: '',
|
||||
conflict: '',
|
||||
secret: '',
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData().name.trim()) return;
|
||||
await createCharacter.mutateAsync({
|
||||
...formData(),
|
||||
projectId: props.projectId,
|
||||
});
|
||||
setShowForm(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
bio: '',
|
||||
role: 'supporting',
|
||||
arc: '',
|
||||
arcType: undefined,
|
||||
age: undefined,
|
||||
gender: '',
|
||||
voice: '',
|
||||
traits: '',
|
||||
motivation: '',
|
||||
conflict: '',
|
||||
secret: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="character-list">
|
||||
<div class="character-list__header">
|
||||
<h2>Characters</h2>
|
||||
<button onClick={() => setShowForm(!showForm())} class="add-character-btn">
|
||||
{showForm() ? 'Cancel' : '+ Add Character'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showForm()}>
|
||||
<div class="character-form">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name *"
|
||||
value={formData().name}
|
||||
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={formData().role}
|
||||
onChange={(e) => setFormData({ ...formData(), role: e.currentTarget.value as Character['role'] })}
|
||||
>
|
||||
<option value="protagonist">Protagonist</option>
|
||||
<option value="antagonist">Antagonist</option>
|
||||
<option value="supporting">Supporting</option>
|
||||
<option value="background">Background</option>
|
||||
<option value="ensemble">Ensemble</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Bio"
|
||||
value={formData().bio}
|
||||
onInput={(e) => setFormData({ ...formData(), bio: e.currentTarget.value })}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
value={formData().description}
|
||||
onInput={(e) => setFormData({ ...formData(), description: e.currentTarget.value })}
|
||||
/>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Traits (comma-separated)"
|
||||
value={formData().traits}
|
||||
onInput={(e) => setFormData({ ...formData(), traits: e.currentTarget.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Voice description"
|
||||
value={formData().voice}
|
||||
onInput={(e) => setFormData({ ...formData(), voice: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Motivation"
|
||||
value={formData().motivation}
|
||||
onInput={(e) => setFormData({ ...formData(), motivation: e.currentTarget.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Conflict"
|
||||
value={formData().conflict}
|
||||
onInput={(e) => setFormData({ ...formData(), conflict: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Character arc"
|
||||
value={formData().arc}
|
||||
onInput={(e) => setFormData({ ...formData(), arc: e.currentTarget.value })}
|
||||
/>
|
||||
<select
|
||||
value={formData().arcType || ''}
|
||||
onChange={(e) => setFormData({ ...formData(), arcType: e.currentTarget.value as Character['arcType'] || undefined })}
|
||||
>
|
||||
<option value="">No arc type</option>
|
||||
<option value="positive">Positive Arc</option>
|
||||
<option value="negative">Negative Arc</option>
|
||||
<option value="flat">Flat Arc</option>
|
||||
<option value="complex">Complex Arc</option>
|
||||
</select>
|
||||
<input
|
||||
type="button"
|
||||
value="Create Character"
|
||||
onClick={handleCreate}
|
||||
class="create-btn"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="character-grid">
|
||||
<For each={charactersQuery.data()}>
|
||||
{(character) => (
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onSelect={setSelectedCharacter}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={selectedCharacter()}>
|
||||
<CharacterProfile
|
||||
character={selectedCharacter()!}
|
||||
onClose={() => setSelectedCharacter(null)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
143
src/components/characters/CharacterProfile.tsx
Normal file
143
src/components/characters/CharacterProfile.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Component, createSignal, Show } from 'solid-js';
|
||||
import { useCharacterStats, useUpdateCharacter, useDeleteCharacter } from '../../../lib/api/trpc-hooks';
|
||||
import { CharacterRelationships } from './CharacterRelationships';
|
||||
import type { Character } from '../../../../server/types/project';
|
||||
|
||||
export interface CharacterProfileProps {
|
||||
character: Character;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const CharacterProfile: Component<CharacterProfileProps> = (props) => {
|
||||
const stats = useCharacterStats(props.character.id);
|
||||
const updateCharacter = useUpdateCharacter();
|
||||
const deleteCharacter = useDeleteCharacter();
|
||||
const [editing, setEditing] = createSignal(false);
|
||||
const [editData, setEditData] = createSignal(props.character);
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateCharacter.mutateAsync(editData());
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteCharacter.mutateAsync(props.character.id);
|
||||
props.onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="character-profile" role="dialog" aria-label={`Profile of ${props.character.name}`}>
|
||||
<div class="character-profile__header">
|
||||
<Show when={props.character.imageUrl}>
|
||||
<img src={props.character.imageUrl} alt={props.character.name} class="character-profile__image" />
|
||||
</Show>
|
||||
<div class="character-profile__header-info">
|
||||
<Show when={!editing()}>
|
||||
<h2>{props.character.name}</h2>
|
||||
</Show>
|
||||
<Show when={editing()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editData().name}
|
||||
onInput={(e) => setEditData({ ...editData(), name: e.currentTarget.value })}
|
||||
/>
|
||||
</Show>
|
||||
<span class="character-profile__role">{props.character.role}</span>
|
||||
</div>
|
||||
<div class="character-profile__actions">
|
||||
<button onClick={() => setEditing(!editing())} class="edit-btn">
|
||||
{editing() ? 'Discard' : 'Edit'}
|
||||
</button>
|
||||
<button onClick={props.onClose} class="close-btn">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="character-profile__body">
|
||||
<div class="character-profile__sections">
|
||||
<section class="profile-section">
|
||||
<h3>Bio</h3>
|
||||
<Show when={!editing()}>
|
||||
<p>{props.character.bio || 'No bio yet.'}</p>
|
||||
</Show>
|
||||
<Show when={editing()}>
|
||||
<textarea
|
||||
value={editData().bio || ''}
|
||||
onInput={(e) => setEditData({ ...editData(), bio: e.currentTarget.value })}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="profile-section">
|
||||
<h3>Character Arc</h3>
|
||||
<Show when={props.character.arcType}>
|
||||
<span class="arc-type-badge">{props.character.arcType} arc</span>
|
||||
</Show>
|
||||
<Show when={!editing()}>
|
||||
<p>{props.character.arc || 'No arc defined.'}</p>
|
||||
</Show>
|
||||
<Show when={editing()}>
|
||||
<textarea
|
||||
value={editData().arc || ''}
|
||||
onInput={(e) => setEditData({ ...editData(), arc: e.currentTarget.value })}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="profile-section">
|
||||
<h3>Traits & Voice</h3>
|
||||
<Show when={props.character.traits}>
|
||||
<p><strong>Traits:</strong> {props.character.traits}</p>
|
||||
</Show>
|
||||
<Show when={props.character.voice}>
|
||||
<p><strong>Voice:</strong> {props.character.voice}</p>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="profile-section">
|
||||
<h3>Motivation & Conflict</h3>
|
||||
<Show when={props.character.motivation}>
|
||||
<p><strong>Motivation:</strong> {props.character.motivation}</p>
|
||||
</Show>
|
||||
<Show when={props.character.conflict}>
|
||||
<p><strong>Conflict:</strong> {props.character.conflict}</p>
|
||||
</Show>
|
||||
<Show when={props.character.secret}>
|
||||
<p><strong>Secret:</strong> {props.character.secret}</p>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="profile-section">
|
||||
<h3>Statistics</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{stats.data()?.sceneCount || 0}</span>
|
||||
<span class="stat-label">Scenes</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{stats.data()?.totalDialogueLines || 0}</span>
|
||||
<span class="stat-label">Dialogue Lines</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{stats.data()?.totalScreenTime || 0}</span>
|
||||
<span class="stat-label">Screen Time (est. min)</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{stats.data()?.relationshipCount || 0}</span>
|
||||
<span class="stat-label">Relationships</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<CharacterRelationships characterId={props.character.id} />
|
||||
</div>
|
||||
|
||||
<Show when={editing()}>
|
||||
<div class="character-profile__footer">
|
||||
<button onClick={handleSave} class="save-btn">Save Changes</button>
|
||||
<button onClick={handleDelete} class="delete-btn">Delete Character</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
src/components/characters/CharacterRelationships.tsx
Normal file
136
src/components/characters/CharacterRelationships.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Component, createSignal, For, Show } from 'solid-js';
|
||||
import {
|
||||
useCharacterRelationships,
|
||||
useCreateRelationship,
|
||||
useUpdateRelationship,
|
||||
useDeleteRelationship,
|
||||
} from '../../../lib/api/trpc-hooks';
|
||||
import type { CharacterRelationship } from '../../../../server/types/project';
|
||||
|
||||
export interface CharacterRelationshipsProps {
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
const relationshipLabels: Record<string, string> = {
|
||||
family: '👨👩👧👦 Family',
|
||||
romantic: '❤️ Romantic',
|
||||
friendship: '🤝 Friendship',
|
||||
rivalry: '⚔️ Rivalry',
|
||||
mentor: '🎓 Mentor',
|
||||
alliance: '🤝 Alliance',
|
||||
conflict: '💥 Conflict',
|
||||
professional: '💼 Professional',
|
||||
other: '📌 Other',
|
||||
};
|
||||
|
||||
export const CharacterRelationships: Component<CharacterRelationshipsProps> = (props) => {
|
||||
const relationships = useCharacterRelationships(props.characterId);
|
||||
const createRelationship = useCreateRelationship();
|
||||
const deleteRelationship = useDeleteRelationship();
|
||||
const [showForm, setShowForm] = createSignal(false);
|
||||
const [newRel, setNewRel] = createSignal({
|
||||
characterIdB: '',
|
||||
relationshipType: 'friendship' as CharacterRelationship['relationshipType'],
|
||||
description: '',
|
||||
strength: 50,
|
||||
isAntagonistic: false,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newRel().characterIdB) return;
|
||||
await createRelationship.mutateAsync({
|
||||
characterIdA: props.characterId,
|
||||
...newRel(),
|
||||
});
|
||||
setShowForm(false);
|
||||
setNewRel({
|
||||
characterIdB: '',
|
||||
relationshipType: 'friendship',
|
||||
description: '',
|
||||
strength: 50,
|
||||
isAntagonistic: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="character-relationships">
|
||||
<div class="relationships-header">
|
||||
<h3>Relationships</h3>
|
||||
<button onClick={() => setShowForm(!showForm())} class="add-relationship-btn">
|
||||
{showForm() ? 'Cancel' : '+ Add Relationship'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showForm()}>
|
||||
<div class="relationship-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Other character ID"
|
||||
value={newRel().characterIdB}
|
||||
onInput={(e) => setNewRel({ ...newRel(), characterIdB: e.currentTarget.value })}
|
||||
/>
|
||||
<select
|
||||
value={newRel().relationshipType}
|
||||
onChange={(e) => setNewRel({ ...newRel(), relationshipType: e.currentTarget.value as CharacterRelationship['relationshipType'] })}
|
||||
>
|
||||
<For each={Object.entries(relationshipLabels)}>
|
||||
{([key, label]) => (
|
||||
<option value={key}>{label}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={newRel().strength}
|
||||
onChange={(e) => setNewRel({ ...newRel(), strength: parseInt(e.currentTarget.value) })}
|
||||
/>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRel().isAntagonistic}
|
||||
onChange={(e) => setNewRel({ ...newRel(), isAntagonistic: e.currentTarget.checked })}
|
||||
/>
|
||||
Antagonistic
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
value={newRel().description}
|
||||
onInput={(e) => setNewRel({ ...newRel(), description: e.currentTarget.value })}
|
||||
/>
|
||||
<button onClick={handleCreate} class="create-relationship-btn">Create</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="relationship-list">
|
||||
<For each={relationships.data()}>
|
||||
{(rel) => (
|
||||
<div class={`relationship-item ${rel.isAntagonistic ? 'antagonistic' : 'positive'}`}>
|
||||
<div class="relationship-info">
|
||||
<span class="relationship-type">{relationshipLabels[rel.relationshipType]}</span>
|
||||
<span class="relationship-strength">
|
||||
Strength: {rel.strength}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relationship-detail">
|
||||
<Show when={rel.description}>
|
||||
<span>{rel.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteRelationship.mutateAsync(rel.id)}
|
||||
class="delete-relationship-btn"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!relationships.data() || relationships.data()!.length === 0}>
|
||||
<div class="no-relationships">No relationships defined yet.</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/characters/CharacterSearch.tsx
Normal file
71
src/components/characters/CharacterSearch.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Component, createSignal, For, Show } from 'solid-js';
|
||||
import { useSearchCharacters } from '../../../lib/api/trpc-hooks';
|
||||
import { CharacterCard } from './CharacterCard';
|
||||
import type { Character } from '../../../../server/types/project';
|
||||
|
||||
export interface CharacterSearchProps {
|
||||
projectId: string;
|
||||
onCharacterSelect?: (character: Character) => void;
|
||||
}
|
||||
|
||||
export const CharacterSearch: Component<CharacterSearchProps> = (props) => {
|
||||
const [query, setQuery] = createSignal('');
|
||||
const [role, setRole] = createSignal<string>('');
|
||||
const [arcType, setArcType] = createSignal<string>('');
|
||||
const results = useSearchCharacters(props.projectId, query, role, arcType);
|
||||
|
||||
const handleSearch = () => {
|
||||
results.refetch();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('');
|
||||
setRole('');
|
||||
setArcType('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="character-search">
|
||||
<div class="search-controls">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search characters by name, bio, traits..."
|
||||
value={query()}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
class="search-input"
|
||||
/>
|
||||
<select value={role()} onChange={(e) => setRole(e.currentTarget.value)} class="filter-select">
|
||||
<option value="">All Roles</option>
|
||||
<option value="protagonist">Protagonist</option>
|
||||
<option value="antagonist">Antagonist</option>
|
||||
<option value="supporting">Supporting</option>
|
||||
<option value="background">Background</option>
|
||||
<option value="ensemble">Ensemble</option>
|
||||
</select>
|
||||
<select value={arcType()} onChange={(e) => setArcType(e.currentTarget.value)} class="filter-select">
|
||||
<option value="">All Arcs</option>
|
||||
<option value="positive">Positive Arc</option>
|
||||
<option value="negative">Negative Arc</option>
|
||||
<option value="flat">Flat Arc</option>
|
||||
<option value="complex">Complex Arc</option>
|
||||
</select>
|
||||
<button onClick={handleSearch} class="search-btn">Search</button>
|
||||
<button onClick={handleClear} class="clear-btn">Clear</button>
|
||||
</div>
|
||||
<div class="search-results">
|
||||
<For each={results.data()}>
|
||||
{(character) => (
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onSelect={props.onCharacterSelect}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!results.data() || results.data()!.length === 0}>
|
||||
<div class="no-results">No characters found</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
src/components/characters/CharacterStatsPanel.tsx
Normal file
54
src/components/characters/CharacterStatsPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, createMemo, For, Show } from 'solid-js';
|
||||
import { useProjectCharacterStats, useCharacters } from '../../../lib/api/trpc-hooks';
|
||||
|
||||
export interface CharacterStatsPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const CharacterStatsPanel: Component<CharacterStatsPanelProps> = (props) => {
|
||||
const stats = useProjectCharacterStats(props.projectId);
|
||||
const characters = useCharacters(props.projectId);
|
||||
|
||||
const characterMap = createMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
characters.data()?.forEach(c => map.set(c.id, c.name));
|
||||
return map;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="character-stats-panel">
|
||||
<h3>Character Statistics</h3>
|
||||
<Show when={stats.data() && stats.data()!.length > 0}>
|
||||
<div class="stats-table-container">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Character</th>
|
||||
<th>Scenes</th>
|
||||
<th>Dialogue Lines</th>
|
||||
<th>Screen Time</th>
|
||||
<th>Relationships</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={stats.data()}>
|
||||
{(stat) => (
|
||||
<tr>
|
||||
<td>{characterMap().get(stat.characterId) || stat.characterId}</td>
|
||||
<td>{stat.sceneCount}</td>
|
||||
<td>{stat.totalDialogueLines}</td>
|
||||
<td>{stat.totalScreenTime} min</td>
|
||||
<td>{stat.relationshipCount}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!stats.data() || stats.data()!.length === 0}>
|
||||
<div class="no-stats">No statistics available.</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
src/components/characters/index.ts
Normal file
6
src/components/characters/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { CharacterList } from './CharacterList';
|
||||
export { CharacterCard } from './CharacterCard';
|
||||
export { CharacterProfile } from './CharacterProfile';
|
||||
export { CharacterSearch } from './CharacterSearch';
|
||||
export { CharacterRelationships } from './CharacterRelationships';
|
||||
export { CharacterStatsPanel } from './CharacterStatsPanel';
|
||||
49
src/db/schema/characters.ts
Normal file
49
src/db/schema/characters.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { scripts } from "./scripts";
|
||||
|
||||
export const characters = sqliteTable("characters", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
scriptId: integer("script_id")
|
||||
.notNull()
|
||||
.references(() => scripts.id),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
role: text("role", { enum: ["protagonist", "antagonist", "supporting", "background", "ensemble"] }).notNull().default("supporting"),
|
||||
bio: text("bio"),
|
||||
description: text("description"),
|
||||
arc: text("arc"),
|
||||
arcType: text("arc_type", { enum: ["positive", "negative", "flat", "complex"] }),
|
||||
age: integer("age"),
|
||||
gender: text("gender"),
|
||||
voice: text("voice"),
|
||||
traits: text("traits"),
|
||||
motivation: text("motivation"),
|
||||
conflict: text("conflict"),
|
||||
secret: text("secret"),
|
||||
imageUrl: text("image_url"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const characterRelationships = sqliteTable("character_relationships", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
characterIdA: integer("character_a_id")
|
||||
.notNull()
|
||||
.references(() => characters.id),
|
||||
characterIdB: integer("character_b_id")
|
||||
.notNull()
|
||||
.references(() => characters.id),
|
||||
relationshipType: text("relationship_type", {
|
||||
enum: ["family", "romantic", "friendship", "rivalry", "mentor", "alliance", "conflict", "professional", "other"],
|
||||
}).notNull(),
|
||||
description: text("description"),
|
||||
strength: integer("strength").notNull().default(50),
|
||||
isAntagonistic: integer("is_antagonistic", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export type Character = typeof characters.$inferSelect;
|
||||
export type NewCharacter = typeof characters.$inferInsert;
|
||||
export type CharacterRelationship = typeof characterRelationships.$inferSelect;
|
||||
export type NewCharacterRelationship = typeof characterRelationships.$inferInsert;
|
||||
6
src/db/schema/index.ts
Normal file
6
src/db/schema/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { users, type User, type NewUser } from "./users";
|
||||
export { projects, type Project, type NewProject } from "./projects";
|
||||
export { scripts, type Script, type NewScript } from "./scripts";
|
||||
export { characters, characterRelationships, type Character, type NewCharacter, type CharacterRelationship, type NewCharacterRelationship } from "./characters";
|
||||
export { scenes, sceneCharacters, type Scene, type NewScene, type SceneCharacter, type NewSceneCharacter } from "./scenes";
|
||||
export { revisions, revisionChanges, type Revision, type NewRevision, type RevisionChange, type NewRevisionChange } from "./revisions";
|
||||
31
src/lib/api/trpc-client.ts
Normal file
31
src/lib/api/trpc-client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../../../server/trpc';
|
||||
|
||||
// Create tRPC client
|
||||
export function createTRPCClientInstance(baseUrl: string = 'http://localhost:8080') {
|
||||
return createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl}/trpc`,
|
||||
headers: () => {
|
||||
// Add auth headers if available
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Helper for SSR
|
||||
export function createServerTRPCClient(baseUrl: string = 'http://localhost:8080') {
|
||||
return createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
266
src/lib/api/trpc-hooks.ts
Normal file
266
src/lib/api/trpc-hooks.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { createQuery, createMutation, useQueryClient } from '@tanstack/solid-query';
|
||||
import { createTRPCClientInstance } from './trpc-client';
|
||||
import type { Project, Character, CharacterRelationship, CharacterStats, Scene } from '../../../server/types/project';
|
||||
|
||||
const trpcClient = createTRPCClientInstance();
|
||||
|
||||
// Project hooks
|
||||
export function useProjects() {
|
||||
return createQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.listProjects.query();
|
||||
return result as Project[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(projectId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['project', projectId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getProject.query({ id: projectId });
|
||||
return result as Project;
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
return createMutation({
|
||||
mutationFn: async (input: { name: string; description?: string }) => {
|
||||
return trpcClient.project.createProject.mutate(input) as Promise<Project>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
const qc = useQueryClient();
|
||||
qc.invalidateQueries({ queryKey: ['projects'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Character hooks
|
||||
export function useCharacters(projectId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['characters', projectId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.listCharacters.query({ projectId });
|
||||
return result as Character[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCharacter(characterId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['character', characterId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getCharacter.query({ id: characterId });
|
||||
return result as Character;
|
||||
},
|
||||
enabled: !!characterId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchCharacters(projectId: string, query?: string, role?: string, arcType?: string) {
|
||||
return createQuery({
|
||||
queryKey: ['searchCharacters', projectId, query, role, arcType],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.searchCharacters.query({
|
||||
projectId,
|
||||
query,
|
||||
role,
|
||||
arcType,
|
||||
});
|
||||
return result as Character[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCharacterStats(characterId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['characterStats', characterId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getCharacterStats.query({ characterId });
|
||||
return result as CharacterStats;
|
||||
},
|
||||
enabled: !!characterId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectCharacterStats(projectId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['projectCharacterStats', projectId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getProjectCharacterStats.query({ projectId });
|
||||
return result as CharacterStats[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCharacter() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
bio?: string;
|
||||
role?: 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble';
|
||||
arc?: string;
|
||||
arcType?: 'positive' | 'negative' | 'flat' | 'complex';
|
||||
age?: number;
|
||||
gender?: string;
|
||||
voice?: string;
|
||||
traits?: string;
|
||||
motivation?: string;
|
||||
conflict?: string;
|
||||
secret?: string;
|
||||
imageUrl?: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
return trpcClient.project.createCharacter.mutate(input) as Promise<Character>;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['characters', variables.projectId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCharacter() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
bio?: string;
|
||||
role?: 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble';
|
||||
arc?: string;
|
||||
arcType?: 'positive' | 'negative' | 'flat' | 'complex';
|
||||
age?: number;
|
||||
gender?: string;
|
||||
voice?: string;
|
||||
traits?: string;
|
||||
motivation?: string;
|
||||
conflict?: string;
|
||||
secret?: string;
|
||||
imageUrl?: string;
|
||||
projectId?: string;
|
||||
}) => {
|
||||
return trpcClient.project.updateCharacter.mutate(input) as Promise<Character>;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['character', variables.id] });
|
||||
qc.invalidateQueries({ queryKey: ['characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return trpcClient.project.deleteCharacter.mutate({ id }) as Promise<{ success: boolean }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['characters'] });
|
||||
qc.invalidateQueries({ queryKey: ['characterRelationships'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Relationship hooks
|
||||
export function useRelationships(projectId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['characterRelationships', projectId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.listRelationships.query({ projectId });
|
||||
return result as CharacterRelationship[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCharacterRelationships(characterId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['characterRelationships', characterId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getRelationshipsForCharacter.query({ characterId });
|
||||
return result as CharacterRelationship[];
|
||||
},
|
||||
enabled: !!characterId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRelationship() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (input: {
|
||||
characterIdA: string;
|
||||
characterIdB: string;
|
||||
relationshipType: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other';
|
||||
description?: string;
|
||||
strength?: number;
|
||||
isAntagonistic?: boolean;
|
||||
}) => {
|
||||
return trpcClient.project.createRelationship.mutate(input) as Promise<CharacterRelationship>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['characterRelationships'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRelationship() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (input: {
|
||||
id: string;
|
||||
relationshipType?: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other';
|
||||
description?: string;
|
||||
strength?: number;
|
||||
isAntagonistic?: boolean;
|
||||
}) => {
|
||||
return trpcClient.project.updateRelationship.mutate(input) as Promise<CharacterRelationship>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['characterRelationships'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRelationship() {
|
||||
const qc = useQueryClient();
|
||||
return createMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return trpcClient.project.deleteRelationship.mutate({ id }) as Promise<{ success: boolean }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['characterRelationships'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Scene hooks
|
||||
export function useScenes(projectId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['scenes', projectId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.listScenes.query({ projectId });
|
||||
return result as Scene[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScene(sceneId: string) {
|
||||
return createQuery({
|
||||
queryKey: ['scene', sceneId],
|
||||
queryFn: async () => {
|
||||
const result = await trpcClient.project.getScene.query({ id: sceneId });
|
||||
return result as Scene;
|
||||
},
|
||||
enabled: !!sceneId,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user