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:
FrenoCorp Agent
2026-04-24 02:24:31 -04:00
committed by Michael Freno
parent 0fcd91cf87
commit 8dc4827597
18 changed files with 2237 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index';
describe('tRPC API Layer - Character System', () => {
let ctx: { userId: string };
let projectId: string;
beforeEach(async () => {
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
const project = await appRouter.project.createProject.mutate({
input: { name: 'Character System Test Project' },
ctx,
});
projectId = project.id;
});
describe('createCharacter', () => {
it('should create a character with all profile fields', async () => {
const character = await appRouter.project.createCharacter.mutate({
input: {
name: 'John Doe',
bio: 'A brave hero',
role: 'protagonist',
arc: 'Grows from coward to leader',
arcType: 'positive',
age: 30,
gender: 'male',
voice: 'Deep, commanding',
traits: 'Brave, loyal, stubborn',
motivation: 'Protect his family',
conflict: 'Internal fear of failure',
secret: 'Afraid of heights',
projectId,
},
ctx,
});
expect(character).toMatchObject({
name: 'John Doe',
bio: 'A brave hero',
role: 'protagonist',
arcType: 'positive',
age: 30,
projectId,
});
expect(character.slug).toBe('john-doe');
});
it('should default role to supporting when not provided', async () => {
const character = await appRouter.project.createCharacter.mutate({
input: {
name: 'Jane Smith',
projectId,
},
ctx,
});
expect(character.role).toBe('supporting');
});
});
describe('updateCharacter', () => {
it('should update character profile fields', async () => {
const created = await appRouter.project.createCharacter.mutate({
input: { name: 'Original', projectId },
ctx,
});
const updated = await appRouter.project.updateCharacter.mutate({
input: {
id: created.id,
name: 'Updated Name',
bio: 'New bio',
role: 'antagonist',
},
ctx,
});
expect(updated.name).toBe('Updated Name');
expect(updated.slug).toBe('updated-name');
expect(updated.bio).toBe('New bio');
expect(updated.role).toBe('antagonist');
});
});
describe('searchCharacters', () => {
it('should filter characters by query', async () => {
await appRouter.project.createCharacter.mutate({
input: { name: 'Alice', bio: 'The hero', projectId },
ctx,
});
await appRouter.project.createCharacter.mutate({
input: { name: 'Bob', bio: 'The villain', projectId },
ctx,
});
const results = await appRouter.project.searchCharacters.query({
input: { projectId, query: 'hero' },
ctx,
});
expect(results.length).toBe(1);
expect(results[0].name).toBe('Alice');
});
it('should filter characters by role', async () => {
await appRouter.project.createCharacter.mutate({
input: { name: 'Protag', role: 'protagonist', projectId },
ctx,
});
await appRouter.project.createCharacter.mutate({
input: { name: 'Antag', role: 'antagonist', projectId },
ctx,
});
const results = await appRouter.project.searchCharacters.query({
input: { projectId, role: 'protagonist' },
ctx,
});
expect(results.length).toBe(1);
expect(results[0].name).toBe('Protag');
});
});
describe('createRelationship', () => {
it('should create a relationship between two characters', async () => {
const charA = await appRouter.project.createCharacter.mutate({
input: { name: 'Character A', projectId },
ctx,
});
const charB = await appRouter.project.createCharacter.mutate({
input: { name: 'Character B', projectId },
ctx,
});
const rel = await appRouter.project.createRelationship.mutate({
input: {
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
strength: 80,
isAntagonistic: false,
},
ctx,
});
expect(rel.characterIdA).toBe(charA.id);
expect(rel.characterIdB).toBe(charB.id);
expect(rel.relationshipType).toBe('friendship');
expect(rel.strength).toBe(80);
});
it('should prevent self-relationships', async () => {
const charA = await appRouter.project.createCharacter.mutate({
input: { name: 'Character A', projectId },
ctx,
});
await expect(
appRouter.project.createRelationship.mutate({
input: {
characterIdA: charA.id,
characterIdB: charA.id,
relationshipType: 'friendship',
},
ctx,
})
).rejects.toThrow('Cannot create a relationship with the same character');
});
it('should prevent duplicate relationships', async () => {
const charA = await appRouter.project.createCharacter.mutate({
input: { name: 'Character A', projectId },
ctx,
});
const charB = await appRouter.project.createCharacter.mutate({
input: { name: 'Character B', projectId },
ctx,
});
await appRouter.project.createRelationship.mutate({
input: {
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
},
ctx,
});
await expect(
appRouter.project.createRelationship.mutate({
input: {
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'rivalry',
},
ctx,
})
).rejects.toThrow('Relationship already exists between these characters');
});
});
describe('deleteCharacter', () => {
it('should remove associated relationships when deleting a character', async () => {
const charA = await appRouter.project.createCharacter.mutate({
input: { name: 'Character A', projectId },
ctx,
});
const charB = await appRouter.project.createCharacter.mutate({
input: { name: 'Character B', projectId },
ctx,
});
await appRouter.project.createRelationship.mutate({
input: {
characterIdA: charA.id,
characterIdB: charB.id,
relationshipType: 'friendship',
},
ctx,
});
await appRouter.project.deleteCharacter.mutate({
input: { id: charA.id },
ctx,
});
const rels = await appRouter.project.getRelationshipsForCharacter.query({
input: { characterId: charB.id },
ctx,
});
expect(rels.length).toBe(0);
});
});
describe('getCharacterStats', () => {
it('should return stats for a character', async () => {
const charA = await appRouter.project.createCharacter.mutate({
input: { name: 'TestChar', projectId },
ctx,
});
const stats = await appRouter.project.getCharacterStats.query({
input: { characterId: charA.id },
ctx,
});
expect(stats.characterId).toBe(charA.id);
expect(stats.sceneCount).toBe(0);
expect(stats.totalDialogueLines).toBe(0);
expect(stats.relationshipCount).toBe(0);
});
});
});

41
server/trpc/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import { initHTTPServer } from '@trpc/server/adapters/http';
import { projectRouter } from './project-router';
import type { TRPCContext } from './types';
import type { TRPCError } from '@trpc/server';
// App router combining all routers
export const appRouter = {
project: projectRouter,
};
export type AppRouter = typeof appRouter;
// Create tRPC HTTP server
export function createTRPCServer(port: number = 8080) {
const server = initHTTPServer({
router: appRouter,
createContext: async ({ req }: { req: Request }): Promise<TRPCContext> => {
// Extract auth from headers
const authHeader = req.headers.get('authorization');
const userId = authHeader?.split(' ')[1]; // Bearer token
return {
userId,
};
},
onError: ({ error, path, input }: { error: TRPCError; path: string; input: unknown }) => {
console.error(`tRPC error on ${path}:`, {
input,
error: error.message,
});
},
});
server.listen(port, () => {
console.log(`tRPC server listening on port ${port}`);
});
return server;
}
export default appRouter;

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index';
import type { Project } from '../../server/types/project';
describe('tRPC API Layer', () => {
let ctx: { userId: string };
beforeEach(() => {
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
});
describe('Project CRUD', () => {
it('should create a project', async () => {
const project = await appRouter.project.createProject.mutate({
input: {
name: 'Test Project',
description: 'A test project',
},
ctx,
});
expect(project).toMatchObject({
name: 'Test Project',
description: 'A test project',
userId: ctx.userId,
});
expect(project.id).toBeDefined();
expect(project.createdAt).toBeInstanceOf(Date);
expect(project.updatedAt).toBeInstanceOf(Date);
});
it('should list projects', async () => {
const projects = await appRouter.project.listProjects.query({
ctx: { userId: ctx.userId },
});
expect(Array.isArray(projects)).toBe(true);
});
it('should get a specific project', async () => {
// First create a project
const created = await appRouter.project.createProject.mutate({
input: { name: 'Get Test' },
ctx,
});
const project = await appRouter.project.getProject.query({
input: { id: created.id },
ctx,
});
expect(project.id).toBe(created.id);
expect(project.name).toBe('Get Test');
});
it('should update a project', async () => {
const created = await appRouter.project.createProject.mutate({
input: { name: 'Update Test' },
ctx,
});
const updated = await appRouter.project.updateProject.mutate({
input: {
id: created.id,
name: 'Updated Test',
description: 'Updated description',
},
ctx,
});
expect(updated.name).toBe('Updated Test');
expect(updated.description).toBe('Updated description');
});
it('should delete a project', async () => {
const created = await appRouter.project.createProject.mutate({
input: { name: 'Delete Test' },
ctx,
});
const result = await appRouter.project.deleteProject.mutate({
input: { id: created.id },
ctx,
});
expect(result).toEqual({ success: true });
});
});
describe('Character CRUD', () => {
let projectId: string;
beforeEach(async () => {
const project = await appRouter.project.createProject.mutate({
input: { name: 'Character Test Project' },
ctx,
});
projectId = project.id;
});
it('should create a character', async () => {
const character = await appRouter.project.createCharacter.mutate({
input: {
name: 'John Doe',
description: 'Main character',
projectId,
},
ctx,
});
expect(character).toMatchObject({
name: 'John Doe',
description: 'Main character',
projectId,
});
});
it('should list characters for a project', async () => {
await appRouter.project.createCharacter.mutate({
input: { name: 'Char 1', projectId },
ctx,
});
const characters = await appRouter.project.listCharacters.query({
input: { projectId },
ctx,
});
expect(characters.length).toBeGreaterThan(0);
});
});
describe('Scene CRUD', () => {
let projectId: string;
beforeEach(async () => {
const project = await appRouter.project.createProject.mutate({
input: { name: 'Scene Test Project' },
ctx,
});
projectId = project.id;
});
it('should create a scene', async () => {
const scene = await appRouter.project.createScene.mutate({
input: {
title: 'INT. OFFICE - DAY',
content: 'John sits at his desk.',
projectId,
order: 1,
},
ctx,
});
expect(scene).toMatchObject({
title: 'INT. OFFICE - DAY',
content: 'John sits at his desk.',
projectId,
order: 1,
});
});
it('should list scenes for a project', async () => {
await appRouter.project.createScene.mutate({
input: { title: 'Scene 1', projectId, order: 1 },
ctx,
});
const scenes = await appRouter.project.listScenes.query({
input: { projectId },
ctx,
});
expect(scenes.length).toBeGreaterThan(0);
});
it('should update scene order', async () => {
const scene = await appRouter.project.createScene.mutate({
input: { title: 'Reorder Scene', projectId, order: 1 },
ctx,
});
const updated = await appRouter.project.updateScene.mutate({
input: { id: scene.id, order: 5 },
ctx,
});
expect(updated.order).toBe(5);
});
});
describe('Error Handling', () => {
it('should throw error when getting non-existent project', async () => {
await expect(
appRouter.project.getProject.query({
input: { id: '00000000-0000-0000-0000-000000000000' },
ctx,
})
).rejects.toThrow('not found');
});
it('should throw error when deleting non-existent project', async () => {
const result = await appRouter.project.deleteProject.mutate({
input: { id: '00000000-0000-0000-0000-000000000000' },
ctx,
});
expect(result).toEqual({ success: false });
});
});
});

View File

@@ -0,0 +1,482 @@
import { publicProcedure, protectedProcedure, projectProcedure } from './router';
import { z } from 'zod';
import type { Project, Character, Scene, CharacterRelationship, CharacterStats } from '../types/project';
// In-memory storage (replace with database later)
const projects: Map<string, Project> = new Map();
const characters: Map<string, Character> = new Map();
const characterRelationships: Map<string, CharacterRelationship> = new Map();
const scenes: Map<string, Scene> = new Map();
// Helpers
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function findCharacterByProject(characterId: string, projectId: string): Character | undefined {
const character = characters.get(characterId);
return character?.projectId === projectId ? character : undefined;
}
function getCharacterStats(characterId: string): CharacterStats {
const characterScenes = Array.from(scenes.values())
.filter(s => s.projectId === characters.get(characterId)?.projectId);
const characterRels = Array.from(characterRelationships.values())
.filter(r => r.characterIdA === characterId || r.characterIdB === characterId);
let totalDialogueLines = 0;
let totalScreenTime = 0;
let sceneCount = 0;
for (const scene of characterScenes) {
if (scene.content.toLowerCase().includes(characters.get(characterId)?.name.toLowerCase() || '')) {
sceneCount++;
const dialogueMatches = scene.content.match(/^[A-Z][A-Z\s,.-]+:/gm);
if (dialogueMatches) {
totalDialogueLines += dialogueMatches.length;
}
totalScreenTime += Math.ceil(scene.content.split('\n').length / 15);
}
}
return {
characterId,
totalScreenTime,
totalDialogueLines,
sceneCount,
relationshipCount: characterRels.length,
};
}
// Character search with filtering
function searchCharacters(projectId: string, query?: string, role?: string, arcType?: string): Character[] {
let results = Array.from(characters.values())
.filter(c => c.projectId === projectId);
if (query) {
const q = query.toLowerCase();
results = results.filter(c =>
c.name.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.bio?.toLowerCase().includes(q) ||
c.traits?.toLowerCase().includes(q) ||
c.motivation?.toLowerCase().includes(q)
);
}
if (role) {
results = results.filter(c => c.role === role);
}
if (arcType) {
results = results.filter(c => c.arcType === arcType);
}
return results;
}
export const projectRouter = {
// Project procedures
listProjects: publicProcedure.query(async ({ ctx }) => {
return Array.from(projects.values());
}),
getProject: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const project = projects.get(input.id);
if (!project) {
throw new Error(`Project ${input.id} not found`);
}
return project;
}),
createProject: protectedProcedure
.input(z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const project: Project = {
id: crypto.randomUUID(),
name: input.name,
description: input.description,
userId: ctx.userId!,
createdAt: new Date(),
updatedAt: new Date(),
};
projects.set(project.id, project);
return project;
}),
updateProject: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
}))
.mutation(async ({ input }) => {
const project = projects.get(input.id);
if (!project) {
throw new Error(`Project ${input.id} not found`);
}
const updated: Project = {
...project,
...(input.name && { name: input.name }),
...(input.description !== undefined && { description: input.description }),
updatedAt: new Date(),
};
projects.set(updated.id, updated);
return updated;
}),
deleteProject: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
const deleted = projects.delete(input.id);
if (!deleted) {
throw new Error(`Project ${input.id} not found`);
}
return { success: true };
}),
// Character CRUD procedures
listCharacters: projectProcedure.query(async ({ ctx }) => {
return Array.from(characters.values())
.filter(char => char.projectId === ctx.projectId);
}),
getCharacter: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const character = characters.get(input.id);
if (!character) {
throw new Error(`Character ${input.id} not found`);
}
return character;
}),
createCharacter: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.string().uuid(),
}))
.mutation(async ({ input }) => {
const slug = slugify(input.name);
const character: Character = {
id: crypto.randomUUID(),
name: input.name,
slug,
description: input.description,
bio: input.bio,
role: input.role || 'supporting',
arc: input.arc,
arcType: input.arcType,
age: input.age,
gender: input.gender,
voice: input.voice,
traits: input.traits,
motivation: input.motivation,
conflict: input.conflict,
secret: input.secret,
imageUrl: input.imageUrl,
projectId: input.projectId,
createdAt: new Date(),
updatedAt: new Date(),
};
characters.set(character.id, character);
return character;
}),
updateCharacter: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.string().uuid().optional(),
}))
.mutation(async ({ input }) => {
const character = characters.get(input.id);
if (!character) {
throw new Error(`Character ${input.id} not found`);
}
const updatedName = input.name || character.name;
const updated: Character = {
...character,
name: updatedName,
slug: slugify(updatedName),
...(input.description !== undefined && { description: input.description }),
...(input.bio !== undefined && { bio: input.bio }),
...(input.role && { role: input.role }),
...(input.arc !== undefined && { arc: input.arc }),
...(input.arcType && { arcType: input.arcType }),
...(input.age !== undefined && { age: input.age }),
...(input.gender !== undefined && { gender: input.gender }),
...(input.voice !== undefined && { voice: input.voice }),
...(input.traits !== undefined && { traits: input.traits }),
...(input.motivation !== undefined && { motivation: input.motivation }),
...(input.conflict !== undefined && { conflict: input.conflict }),
...(input.secret !== undefined && { secret: input.secret }),
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
...(input.projectId && { projectId: input.projectId }),
updatedAt: new Date(),
};
characters.set(updated.id, updated);
return updated;
}),
deleteCharacter: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
const character = characters.get(input.id);
if (!character) {
throw new Error(`Character ${input.id} not found`);
}
// Remove associated relationships
for (const [relId, rel] of characterRelationships) {
if (rel.characterIdA === input.id || rel.characterIdB === input.id) {
characterRelationships.delete(relId);
}
}
const deleted = characters.delete(input.id);
if (!deleted) {
throw new Error(`Character ${input.id} not found`);
}
return { success: true };
}),
searchCharacters: protectedProcedure
.input(z.object({
projectId: z.string().uuid(),
query: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
}))
.query(async ({ input }) => {
return searchCharacters(input.projectId, input.query, input.role, input.arcType);
}),
getCharacterStats: protectedProcedure
.input(z.object({ characterId: z.string().uuid() }))
.query(async ({ input }) => {
if (!characters.has(input.characterId)) {
throw new Error(`Character ${input.characterId} not found`);
}
return getCharacterStats(input.characterId);
}),
getProjectCharacterStats: projectProcedure
.query(async ({ ctx }) => {
const projectCharacters = Array.from(characters.values())
.filter(c => c.projectId === ctx.projectId);
return projectCharacters.map(c => getCharacterStats(c.id));
}),
// Relationship procedures
listRelationships: projectProcedure
.query(async ({ ctx }) => {
const projectCharacterIds = new Set(
Array.from(characters.values())
.filter(c => c.projectId === ctx.projectId)
.map(c => c.id)
);
return Array.from(characterRelationships.values())
.filter(r => projectCharacterIds.has(r.characterIdA) && projectCharacterIds.has(r.characterIdB));
}),
getRelationshipsForCharacter: protectedProcedure
.input(z.object({ characterId: z.string().uuid() }))
.query(async ({ input }) => {
const character = characters.get(input.characterId);
if (!character) {
throw new Error(`Character ${input.characterId} not found`);
}
return Array.from(characterRelationships.values())
.filter(r => r.characterIdA === input.characterId || r.characterIdB === input.characterId);
}),
createRelationship: protectedProcedure
.input(z.object({
characterIdA: z.string().uuid(),
characterIdB: z.string().uuid(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
if (input.characterIdA === input.characterIdB) {
throw new Error('Cannot create a relationship with the same character');
}
const charA = characters.get(input.characterIdA);
const charB = characters.get(input.characterIdB);
if (!charA || !charB) {
throw new Error('Both characters must exist');
}
// Check for duplicate relationship
const existing = Array.from(characterRelationships.values()).find(
r => (r.characterIdA === input.characterIdA && r.characterIdB === input.characterIdB) ||
(r.characterIdA === input.characterIdB && r.characterIdB === input.characterIdA)
);
if (existing) {
throw new Error('Relationship already exists between these characters');
}
const relationship: CharacterRelationship = {
id: crypto.randomUUID(),
characterIdA: input.characterIdA,
characterIdB: input.characterIdB,
relationshipType: input.relationshipType,
description: input.description,
strength: input.strength ?? 50,
isAntagonistic: input.isAntagonistic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
};
characterRelationships.set(relationship.id, relationship);
return relationship;
}),
updateRelationship: protectedProcedure
.input(z.object({
id: z.string().uuid(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(),
description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const relationship = characterRelationships.get(input.id);
if (!relationship) {
throw new Error(`Relationship ${input.id} not found`);
}
const updated: CharacterRelationship = {
...relationship,
...(input.relationshipType && { relationshipType: input.relationshipType }),
...(input.description !== undefined && { description: input.description }),
...(input.strength !== undefined && { strength: input.strength }),
...(input.isAntagonistic !== undefined && { isAntagonistic: input.isAntagonistic }),
updatedAt: new Date(),
};
characterRelationships.set(updated.id, updated);
return updated;
}),
deleteRelationship: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
const deleted = characterRelationships.delete(input.id);
if (!deleted) {
throw new Error(`Relationship ${input.id} not found`);
}
return { success: true };
}),
// Scene procedures
listScenes: projectProcedure.query(async ({ ctx }) => {
return Array.from(scenes.values())
.filter(scene => scene.projectId === ctx.projectId)
.sort((a, b) => a.order - b.order);
}),
getScene: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const scene = scenes.get(input.id);
if (!scene) {
throw new Error(`Scene ${input.id} not found`);
}
return scene;
}),
createScene: protectedProcedure
.input(z.object({
title: z.string().min(1),
content: z.string().optional(),
projectId: z.string().uuid(),
order: z.number().int().nonnegative(),
}))
.mutation(async ({ input }) => {
const scene: Scene = {
id: crypto.randomUUID(),
title: input.title,
content: input.content || '',
projectId: input.projectId,
order: input.order,
createdAt: new Date(),
updatedAt: new Date(),
};
scenes.set(scene.id, scene);
return scene;
}),
updateScene: protectedProcedure
.input(z.object({
id: z.string().uuid(),
title: z.string().min(1).optional(),
content: z.string().optional(),
order: z.number().int().nonnegative().optional(),
}))
.mutation(async ({ input }) => {
const scene = scenes.get(input.id);
if (!scene) {
throw new Error(`Scene ${input.id} not found`);
}
const updated: Scene = {
...scene,
...(input.title && { title: input.title }),
...(input.content !== undefined && { content: input.content }),
...(input.order !== undefined && { order: input.order }),
updatedAt: new Date(),
};
scenes.set(updated.id, updated);
return updated;
}),
deleteScene: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
const deleted = scenes.delete(input.id);
if (!deleted) {
throw new Error(`Scene ${input.id} not found`);
}
return { success: true };
}),
};

41
server/trpc/router.ts Normal file
View File

@@ -0,0 +1,41 @@
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { TRPCContext } from './types';
// Initialize tRPC with context
const t = initTRPC.context<TRPCContext>().create();
// Middleware for authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
// Middleware for project authorization
const hasProjectAccess = t.middleware(({ ctx, next }) => {
const projectId = ctx.projectId;
if (!projectId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
}
return next({ ctx: { ...ctx, projectId } });
});
// Base router
export const baseRouter = t.router;
// Procedure builders
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
export const projectProcedure = t.procedure.use(hasProjectAccess);
// Validation middleware
export const validateInput = <T extends z.ZodTypeAny>(schema: T) => {
return t.middleware(({ input, next }) => {
const validated = schema.parse(input);
return next({ input: validated });
});
};
export { t, TRPCError };

172
server/trpc/types.ts Normal file
View File

@@ -0,0 +1,172 @@
import { z } from 'zod';
// Base types
export const ProjectSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
description: z.string().optional(),
userId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CharacterSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
slug: z.string(),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CharacterRelationshipSchema = z.object({
id: z.string().uuid(),
characterIdA: z.string().uuid(),
characterIdB: z.string().uuid(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
description: z.string().optional(),
strength: z.number().int().min(0).max(100),
isAntagonistic: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CharacterStatsSchema = z.object({
characterId: z.string().uuid(),
totalScreenTime: z.number().int(),
totalDialogueLines: z.number().int(),
sceneCount: z.number().int(),
relationshipCount: z.number().int(),
});
export const SceneSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
content: z.string(),
projectId: z.string().uuid(),
order: z.number().int().nonnegative(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const ScriptVersionSchema = z.object({
id: z.string().uuid(),
projectId: z.string().uuid(),
content: z.string(),
version: z.number().int().positive(),
authorId: z.string().uuid().optional(),
createdAt: z.date(),
});
// Input schemas
export const CreateProjectInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
export const UpdateProjectInputSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
});
export const CreateCharacterInputSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.string().uuid(),
});
export const UpdateCharacterInputSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
bio: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arc: z.string().optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
age: z.number().int().optional(),
gender: z.string().optional(),
voice: z.string().optional(),
traits: z.string().optional(),
motivation: z.string().optional(),
conflict: z.string().optional(),
secret: z.string().optional(),
imageUrl: z.string().url().optional(),
projectId: z.string().uuid().optional(),
});
export const CreateRelationshipInputSchema = z.object({
characterIdA: z.string().uuid(),
characterIdB: z.string().uuid(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(),
});
export const UpdateRelationshipInputSchema = z.object({
id: z.string().uuid(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(),
description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(),
isAntagonistic: z.boolean().optional(),
});
export const SearchCharactersInputSchema = z.object({
projectId: z.string().uuid(),
query: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
});
export const CreateSceneInputSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
projectId: z.string().uuid(),
order: z.number().int().nonnegative(),
});
export const UpdateSceneInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).optional(),
content: z.string().optional(),
order: z.number().int().nonnegative().optional(),
});
// Response schemas
export const ProjectListSchema = z.array(ProjectSchema);
export const CharacterListSchema = z.array(CharacterSchema);
export const CharacterRelationshipListSchema = z.array(CharacterRelationshipSchema);
export const CharacterStatsListSchema = z.array(CharacterStatsSchema);
export const SceneListSchema = z.array(SceneSchema);
// Auth context
export interface TRPCContext {
userId?: string;
projectId?: string;
}

35
server/types/project.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { z } from 'zod';
import {
ProjectSchema,
CharacterSchema,
CharacterRelationshipSchema,
CharacterStatsSchema,
SceneSchema,
ScriptVersionSchema,
CreateProjectInputSchema,
UpdateProjectInputSchema,
CreateCharacterInputSchema,
UpdateCharacterInputSchema,
CreateRelationshipInputSchema,
UpdateRelationshipInputSchema,
SearchCharactersInputSchema,
CreateSceneInputSchema,
UpdateSceneInputSchema,
} from '../trpc/types';
export type Project = z.infer<typeof ProjectSchema>;
export type Character = z.infer<typeof CharacterSchema>;
export type CharacterRelationship = z.infer<typeof CharacterRelationshipSchema>;
export type CharacterStats = z.infer<typeof CharacterStatsSchema>;
export type Scene = z.infer<typeof SceneSchema>;
export type ScriptVersion = z.infer<typeof ScriptVersionSchema>;
export type CreateProjectInput = z.infer<typeof CreateProjectInputSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectInputSchema>;
export type CreateCharacterInput = z.infer<typeof CreateCharacterInputSchema>;
export type UpdateCharacterInput = z.infer<typeof UpdateCharacterInputSchema>;
export type CreateRelationshipInput = z.infer<typeof CreateRelationshipInputSchema>;
export type UpdateRelationshipInput = z.infer<typeof UpdateRelationshipInputSchema>;
export type SearchCharactersInput = z.infer<typeof SearchCharactersInputSchema>;
export type CreateSceneInput = z.infer<typeof CreateSceneInputSchema>;
export type UpdateSceneInput = z.infer<typeof UpdateSceneInputSchema>;

View 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>
);
};

View 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>
);
};

View 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">&times;</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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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';

View 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
View 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";

View 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
View 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,
});
}