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:
256
server/trpc/character-router.test.ts
Normal file
256
server/trpc/character-router.test.ts
Normal 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
41
server/trpc/index.ts
Normal 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;
|
||||
211
server/trpc/project-router.test.ts
Normal file
211
server/trpc/project-router.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
482
server/trpc/project-router.ts
Normal file
482
server/trpc/project-router.ts
Normal 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
41
server/trpc/router.ts
Normal 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
172
server/trpc/types.ts
Normal 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
35
server/types/project.ts
Normal 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>;
|
||||
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