diff --git a/server/trpc/index.ts b/server/trpc/index.ts index 95b6b2478..195fe871e 100644 --- a/server/trpc/index.ts +++ b/server/trpc/index.ts @@ -1,12 +1,15 @@ -import { initHTTPServer } from '@trpc/server/adapters/http'; +import { initHTTPServer } from '@trpc/server/adapters/node-http'; import { projectRouter } from './project-router'; +import { revisionsRouter } from './revisions-router'; import type { TRPCContext } from './types'; import type { TRPCError } from '@trpc/server'; +import { t } from './router'; // App router combining all routers -export const appRouter = { +export const appRouter = t.router({ project: projectRouter, -}; + revisions: revisionsRouter, +}); export type AppRouter = typeof appRouter; diff --git a/server/trpc/revisions-router.test.ts b/server/trpc/revisions-router.test.ts new file mode 100644 index 000000000..38df0c711 --- /dev/null +++ b/server/trpc/revisions-router.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { appRouter } from './index'; + +describe('revisionsRouter', () => { + const ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; + + describe('createRevision', () => { + it('should create a revision with version 1', async () => { + const result = await appRouter.revisions.createRevision.mutate({ + input: { + scriptId: 1, + title: 'Initial draft', + content: 'FADE IN:\n\nINT. ROOM - DAY', + authorId: 1, + }, + ctx, + }); + + expect(result.versionNumber).toBe(1); + expect(result.branchName).toBe('main'); + expect(result.status).toBe('draft'); + }); + + it('should increment version number for same script', async () => { + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'v1', content: 'content1', authorId: 1 }, + ctx, + }); + + const result = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'v2', content: 'content2', authorId: 1 }, + ctx, + }); + + expect(result.versionNumber).toBe(2); + }); + + it('should support custom branch', async () => { + const result = await appRouter.revisions.createRevision.mutate({ + input: { + scriptId: 1, + title: 'Branch revision', + content: 'branch content', + branchName: 'feature-act2', + authorId: 1, + }, + ctx, + }); + + expect(result.branchName).toBe('feature-act2'); + }); + }); + + describe('listRevisions', () => { + it('should return empty array for unknown script', async () => { + const result = await appRouter.revisions.listRevisions.query({ + input: { scriptId: 999 }, + ctx, + }); + expect(result).toEqual([]); + }); + + it('should filter by branch', async () => { + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'main v1', content: 'main', branchName: 'main', authorId: 1 }, + ctx, + }); + + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'feature v1', content: 'feature', branchName: 'feature', authorId: 1 }, + ctx, + }); + + const mainRevisions = await appRouter.revisions.listRevisions.query({ + input: { scriptId: 1, branchName: 'main' }, + ctx, + }); + + expect(mainRevisions).toHaveLength(1); + expect(mainRevisions[0]!.branchName).toBe('main'); + }); + }); + + describe('acceptRevision', () => { + it('should accept a revision', async () => { + const created = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'To accept', content: 'content', authorId: 1 }, + ctx, + }); + + const result = await appRouter.revisions.acceptRevision.mutate({ + input: { revisionId: created.id, reviewedById: 2 }, + ctx, + }); + + expect(result.status).toBe('accepted'); + expect(result.reviewedById).toBe(2); + expect(result.reviewedAt).toBeDefined(); + }); + }); + + describe('rejectRevision', () => { + it('should reject a revision with reason', async () => { + const created = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'To reject', content: 'content', authorId: 1 }, + ctx, + }); + + const result = await appRouter.revisions.rejectRevision.mutate({ + input: { revisionId: created.id, reviewedById: 2, reason: 'Needs more work on dialogue' }, + ctx, + }); + + expect(result.status).toBe('rejected'); + expect(result.summary).toContain('Needs more work on dialogue'); + }); + }); + + describe('rollbackToRevision', () => { + it('should create a new revision with old content', async () => { + const original = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'Original', content: 'original content', authorId: 1 }, + ctx, + }); + + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'Changed', content: 'changed content', authorId: 1 }, + ctx, + }); + + const rollback = await appRouter.revisions.rollbackToRevision.mutate({ + input: { scriptId: 1, revisionId: original.id, authorId: 1 }, + ctx, + }); + + expect(rollback.content).toBe('original content'); + expect(rollback.versionNumber).toBe(3); + expect(rollback.title).toContain('Rollback'); + }); + }); + + describe('compareRevisions', () => { + it('should compare two revisions', async () => { + const rev1 = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'v1', content: 'line1\nline2\nline3', authorId: 1 }, + ctx, + }); + + const rev2 = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'v2', content: 'line1\nchanged\nline3', authorId: 1 }, + ctx, + }); + + const result = await appRouter.revisions.compareRevisions.query({ + input: { baseRevisionId: rev1.id, targetRevisionId: rev2.id }, + ctx, + }); + + expect(result.diff.modifications).toBe(1); + expect(result.diff.additions).toBe(0); + expect(result.diff.deletions).toBe(0); + }); + }); + + describe('getTimeline', () => { + it('should return timeline entries in chronological order', async () => { + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'First', content: 'first', authorId: 1 }, + ctx, + }); + + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'Second', content: 'second', authorId: 1 }, + ctx, + }); + + const timeline = await appRouter.revisions.getTimeline.query({ + input: { scriptId: 1 }, + ctx, + }); + + expect(timeline).toHaveLength(2); + expect(timeline[0]!.revision.title).toBe('First'); + expect(timeline[1]!.revision.title).toBe('Second'); + }); + }); + + describe('getBranches', () => { + it('should return branch information', async () => { + await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'main v1', content: 'main', authorId: 1 }, + ctx, + }); + + await appRouter.revisions.createBranch.mutate({ + input: { scriptId: 1, branchName: 'feature', authorId: 1 }, + ctx, + }); + + const branches = await appRouter.revisions.getBranches.query({ + input: { scriptId: 1 }, + ctx, + }); + + expect(branches).toHaveLength(2); + const branchNames = branches.map((b: any) => b.branchName); + expect(branchNames).toContain('main'); + expect(branchNames).toContain('feature'); + }); + }); + + describe('deleteRevision', () => { + it('should delete a revision', async () => { + const created = await appRouter.revisions.createRevision.mutate({ + input: { scriptId: 1, title: 'To delete', content: 'content', authorId: 1 }, + ctx, + }); + + const result = await appRouter.revisions.deleteRevision.mutate({ + input: { id: created.id }, + ctx, + }); + + expect(result.success).toBe(true); + + await expect( + appRouter.revisions.getRevision.query({ + input: { id: created.id }, + ctx, + }) + ).rejects.toThrow(); + }); + }); +}); diff --git a/server/trpc/revisions-router.ts b/server/trpc/revisions-router.ts new file mode 100644 index 000000000..0d93ea8af --- /dev/null +++ b/server/trpc/revisions-router.ts @@ -0,0 +1,561 @@ +import { protectedProcedure } from './router'; +import { z } from 'zod'; + +// In-memory storage +const revisions: Map = new Map(); + +const revisionChanges: Map = new Map(); + +let revisionIdCounter = 0; +let changeIdCounter = 0; + +function getNextRevisionId(): number { + return ++revisionIdCounter; +} + +function getNextChangeId(): number { + return ++changeIdCounter; +} + +function computeDiffForContent( + oldContent: string, + newContent: string, + revisionId: number +): Map { + const oldLines = oldContent.split('\n'); + const newLines = newContent.split('\n'); + const changes = new Map(); + + let sceneCounter = 0; + const linesPerPage = 55; + const maxLen = Math.max(oldLines.length, newLines.length); + + for (let i = 0; i < maxLen; i++) { + const oldLine = oldLines[i]; + const newLine = newLines[i]; + + if (oldLine === newLine) continue; + + let changeType: 'addition' | 'deletion' | 'modification'; + if (!oldLine && newLine) { + changeType = 'addition'; + } else if (oldLine && !newLine) { + changeType = 'deletion'; + } else { + changeType = 'modification'; + } + + if (newLine?.trim().toUpperCase().startsWith('INT.') || + newLine?.trim().toUpperCase().startsWith('EXT.')) { + sceneCounter++; + } + + const change = { + id: getNextChangeId(), + revisionId, + changeType, + elementType: null, + oldContent: changeType !== 'addition' ? oldLine || null : null, + newContent: changeType !== 'deletion' ? newLine || null : null, + sceneNumber: sceneCounter || null, + lineNumber: i + 1, + pageNumber: Math.ceil((i + 1) / linesPerPage), + createdAt: new Date(), + }; + + changes.set(change.id, change); + revisionChanges.set(change.id, change); + } + + return changes; +} + +function getLatestVersionForScript(scriptId: number, branchName: string): number { + let maxVersion = 0; + for (const rev of revisions.values()) { + if (rev.scriptId === scriptId && rev.branchName === branchName) { + if (rev.versionNumber > maxVersion) { + maxVersion = rev.versionNumber; + } + } + } + return maxVersion; +} + +export const revisionsRouter = { + listRevisions: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + branchName: z.string().optional(), + })) + .query(async ({ input }) => { + const results = Array.from(revisions.values()) + .filter(r => r.scriptId === input.scriptId) + .filter(r => !input.branchName || r.branchName === input.branchName) + .sort((a, b) => b.versionNumber - a.versionNumber); + return results; + }), + + getRevision: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + })) + .query(async ({ input }) => { + const revision = revisions.get(input.id); + if (!revision) { + throw new Error(`Revision ${input.id} not found`); + } + return revision; + }), + + createRevision: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + title: z.string().min(1).max(255), + summary: z.string().max(2000).optional(), + content: z.string(), + branchName: z.string().default('main'), + parentRevisionId: z.number().int().positive().optional(), + authorId: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + const nextVersion = getLatestVersionForScript( + input.scriptId, + input.branchName + ) + 1; + + const revision = { + id: getNextRevisionId(), + scriptId: input.scriptId, + versionNumber: nextVersion, + branchName: input.branchName, + parentRevisionId: input.parentRevisionId || null, + title: input.title, + summary: input.summary || null, + content: input.content, + authorId: input.authorId, + status: 'draft' as const, + reviewedById: null, + reviewedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + revisions.set(revision.id, revision); + + if (input.parentRevisionId) { + const parent = revisions.get(input.parentRevisionId); + if (parent) { + computeDiffForContent(parent.content, input.content, revision.id); + } + } + + return revision; + }), + + updateRevision: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + title: z.string().min(1).max(255).optional(), + summary: z.string().max(2000).optional(), + content: z.string().optional(), + status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(), + })) + .mutation(async ({ input }) => { + const revision = revisions.get(input.id); + if (!revision) { + throw new Error(`Revision ${input.id} not found`); + } + + const updated = { + ...revision, + ...(input.title && { title: input.title }), + ...(input.summary !== undefined && { summary: input.summary }), + ...(input.content !== undefined && { content: input.content }), + ...(input.status && { status: input.status }), + updatedAt: new Date(), + }; + + revisions.set(updated.id, updated); + return updated; + }), + + deleteRevision: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + const deleted = revisions.delete(input.id); + if (!deleted) { + throw new Error(`Revision ${input.id} not found`); + } + + for (const [changeId, change] of revisionChanges) { + if (change.revisionId === input.id) { + revisionChanges.delete(changeId); + } + } + + return { success: true }; + }), + + getRevisionChanges: protectedProcedure + .input(z.object({ + revisionId: z.number().int().positive(), + })) + .query(async ({ input }) => { + const revision = revisions.get(input.revisionId); + if (!revision) { + throw new Error(`Revision ${input.revisionId} not found`); + } + + const changes = Array.from(revisionChanges.values()) + .filter(c => c.revisionId === input.revisionId) + .sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0)); + + return changes; + }), + + compareRevisions: protectedProcedure + .input(z.object({ + baseRevisionId: z.number().int().positive(), + targetRevisionId: z.number().int().positive(), + })) + .query(async ({ input }) => { + const baseRevision = revisions.get(input.baseRevisionId); + const targetRevision = revisions.get(input.targetRevisionId); + + if (!baseRevision) { + throw new Error(`Base revision ${input.baseRevisionId} not found`); + } + if (!targetRevision) { + throw new Error(`Target revision ${input.targetRevisionId} not found`); + } + + const oldLines = baseRevision.content.split('\n'); + const newLines = targetRevision.content.split('\n'); + + let additions = 0; + let deletions = 0; + let modifications = 0; + + const maxLen = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < maxLen; i++) { + const oldLine = oldLines[i]; + const newLine = newLines[i]; + + if (oldLine === newLine) continue; + + if (!oldLine && newLine) { + additions++; + } else if (oldLine && !newLine) { + deletions++; + } else { + modifications++; + } + } + + return { + baseRevision, + targetRevision, + diff: { + additions, + deletions, + modifications, + }, + }; + }), + + acceptRevision: protectedProcedure + .input(z.object({ + revisionId: z.number().int().positive(), + reviewedById: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + const revision = revisions.get(input.revisionId); + if (!revision) { + throw new Error(`Revision ${input.revisionId} not found`); + } + + const updated = { + ...revision, + status: 'accepted' as const, + reviewedById: input.reviewedById, + reviewedAt: new Date(), + updatedAt: new Date(), + }; + + revisions.set(updated.id, updated); + return updated; + }), + + rejectRevision: protectedProcedure + .input(z.object({ + revisionId: z.number().int().positive(), + reviewedById: z.number().int().positive(), + reason: z.string().max(1000).optional(), + })) + .mutation(async ({ input }) => { + const revision = revisions.get(input.revisionId); + if (!revision) { + throw new Error(`Revision ${input.revisionId} not found`); + } + + const updated = { + ...revision, + status: 'rejected' as const, + reviewedById: input.reviewedById, + reviewedAt: new Date(), + summary: input.reason + ? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']' + : revision.summary, + updatedAt: new Date(), + }; + + revisions.set(updated.id, updated); + return updated; + }), + + rollbackToRevision: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + revisionId: z.number().int().positive(), + authorId: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + const targetRevision = revisions.get(input.revisionId); + if (!targetRevision) { + throw new Error(`Revision ${input.revisionId} not found`); + } + + if (targetRevision.scriptId !== input.scriptId) { + throw new Error('Revision does not belong to the specified script'); + } + + const nextVersion = getLatestVersionForScript( + input.scriptId, + targetRevision.branchName + ) + 1; + + const rollbackRevision = { + id: getNextRevisionId(), + scriptId: input.scriptId, + versionNumber: nextVersion, + branchName: targetRevision.branchName, + parentRevisionId: targetRevision.id, + title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`, + summary: `Rolled back to revision ${targetRevision.id}`, + content: targetRevision.content, + authorId: input.authorId, + status: 'draft' as const, + reviewedById: null, + reviewedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + revisions.set(rollbackRevision.id, rollbackRevision); + return rollbackRevision; + }), + + getTimeline: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + })) + .query(async ({ input }) => { + const scriptRevisions = Array.from(revisions.values()) + .filter(r => r.scriptId === input.scriptId) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + const timeline = scriptRevisions.map(rev => { + const changes = Array.from(revisionChanges.values()) + .filter(c => c.revisionId === rev.id); + + const additions = changes.filter(c => c.changeType === 'addition').length; + const deletions = changes.filter(c => c.changeType === 'deletion').length; + const modifications = changes.filter(c => c.changeType === 'modification').length; + + return { + revision: rev, + changeCount: changes.length, + additions, + deletions, + modifications, + }; + }); + + return timeline; + }), + + getBranches: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + })) + .query(async ({ input }) => { + const scriptRevisions = Array.from(revisions.values()) + .filter(r => r.scriptId === input.scriptId); + + const branchMap = new Map(); + for (const rev of scriptRevisions) { + if (!branchMap.has(rev.branchName)) { + branchMap.set(rev.branchName, []); + } + branchMap.get(rev.branchName)!.push(rev); + } + + const branches = Array.from(branchMap.entries()).map(([branchName, revs]) => { + const sorted = revs.sort((a, b) => b.versionNumber - a.versionNumber); + const latest = sorted[0]!; + + return { + branchName, + revisionCount: revs.length, + latestVersion: latest.versionNumber, + latestRevision: latest, + }; + }); + + return branches; + }), + + createBranch: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + branchName: z.string().min(1), + fromRevisionId: z.number().int().positive().optional(), + authorId: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + const existing = Array.from(revisions.values()) + .some(r => r.scriptId === input.scriptId && r.branchName === input.branchName); + + if (existing) { + throw new Error(`Branch '${input.branchName}' already exists for this script`); + } + + let sourceContent = ''; + let parentRevisionId: number | null = null; + + if (input.fromRevisionId) { + const source = revisions.get(input.fromRevisionId); + if (!source) { + throw new Error(`Source revision ${input.fromRevisionId} not found`); + } + sourceContent = source.content; + parentRevisionId = source.id; + } else { + const mainRevisions = Array.from(revisions.values()) + .filter(r => r.scriptId === input.scriptId && r.branchName === 'main') + .sort((a, b) => b.versionNumber - a.versionNumber); + + if (mainRevisions.length > 0) { + sourceContent = mainRevisions[0]!.content; + parentRevisionId = mainRevisions[0]!.id; + } + } + + const branchRevision = { + id: getNextRevisionId(), + scriptId: input.scriptId, + versionNumber: 1, + branchName: input.branchName, + parentRevisionId, + title: `Branch: ${input.branchName}`, + summary: null, + content: sourceContent, + authorId: input.authorId, + status: 'draft' as const, + reviewedById: null, + reviewedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + revisions.set(branchRevision.id, branchRevision); + return branchRevision; + }), + + mergeBranch: protectedProcedure + .input(z.object({ + scriptId: z.number().int().positive(), + sourceBranch: z.string(), + targetBranch: z.string(), + authorId: z.number().int().positive(), + })) + .mutation(async ({ input }) => { + if (input.sourceBranch === input.targetBranch) { + throw new Error('Cannot merge a branch into itself'); + } + + const sourceRevisions = Array.from(revisions.values()) + .filter(r => r.scriptId === input.scriptId && r.branchName === input.sourceBranch) + .sort((a, b) => b.versionNumber - a.versionNumber); + + if (sourceRevisions.length === 0) { + throw new Error(`Source branch '${input.sourceBranch}' has no revisions`); + } + + const sourceContent = sourceRevisions[0]!.content; + const nextVersion = getLatestVersionForScript( + input.scriptId, + input.targetBranch + ) + 1; + + const mergeRevision = { + id: getNextRevisionId(), + scriptId: input.scriptId, + versionNumber: nextVersion, + branchName: input.targetBranch, + parentRevisionId: null, + title: `Merge from '${input.sourceBranch}'`, + summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`, + content: sourceContent, + authorId: input.authorId, + status: 'draft' as const, + reviewedById: null, + reviewedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + revisions.set(mergeRevision.id, mergeRevision); + return mergeRevision; + }), +}; diff --git a/src/components/revisions/DiffViewer.tsx b/src/components/revisions/DiffViewer.tsx new file mode 100644 index 000000000..36f777d54 --- /dev/null +++ b/src/components/revisions/DiffViewer.tsx @@ -0,0 +1,182 @@ +import { Component, For, Show, Switch, Match } from 'solid-js'; +import type { RevisionChangeData, ChangeType } from '../../lib/revisions/types'; +import { getChangeColor, getChangeBackgroundColor } from '../../lib/revisions/diff'; + +export interface DiffViewerProps { + changes: RevisionChangeData[]; + baseContent?: string; + targetContent?: string; + viewMode?: 'changes' | 'split' | 'unified'; +} + +export const DiffViewer: Component = (props) => { + const renderChangeLine = (change: RevisionChangeData) => { + const prefix = change.changeType === 'addition' + ? '+' + : change.changeType === 'deletion' + ? '-' + : '~'; + + const content = change.changeType === 'deletion' + ? change.oldContent + : change.newContent; + + return ( +
+ + {prefix} + + {content || ''} + L{change.lineNumber || '?'} +
+ ); + }; + + const renderModification = (change: RevisionChangeData) => { + return ( +
+
+ - + {change.oldContent || ''} +
+
+ - + {change.newContent || ''} +
+
+ ); + }; + + const renderChangesView = () => ( +
+ + {(change) => ( + change.changeType === 'modification' + ? renderModification(change) + : renderChangeLine(change) + )} + +
+ ); + + const renderSplitView = () => { + const baseLines = props.baseContent?.split('\n') || []; + const targetLines = props.targetContent?.split('\n') || []; + + return ( +
+
+
Original
+
+ + {(line, i) => ( +
+ {i() + 1} + {line} +
+ )} +
+
+
+
+
Revised
+
+ + {(line, i) => ( +
+ {i() + 1} + {line} +
+ )} +
+
+
+
+ ); + }; + + const renderUnifiedView = () => { + const baseLines = props.baseContent?.split('\n') || []; + const targetLines = props.targetContent?.split('\n') || []; + const maxLen = Math.max(baseLines.length, targetLines.length); + + return ( +
+ i)}> + {(lineIdx) => { + const baseLine = baseLines[lineIdx]; + const targetLine = targetLines[lineIdx]; + const isNew = !baseLine && targetLine; + const isDeleted = baseLine && !targetLine; + const isModified = baseLine && targetLine && baseLine !== targetLine; + const isUnchanged = baseLine === targetLine; + + const changeType = isNew + ? 'addition' + : isDeleted + ? 'deletion' + : isModified + ? 'modification' + : null; + + return ( +
+ {lineIdx + 1} + + {targetLine} + +
+ ); + }} +
+
+ ); + }; + + return ( +
+ +
+

No differences to display.

+
+
+ + + + {renderSplitView()} + + + {renderUnifiedView()} + + + {renderChangesView()} + + +
+ ); +}; + +export default DiffViewer; diff --git a/src/components/revisions/RevisionReview.tsx b/src/components/revisions/RevisionReview.tsx new file mode 100644 index 000000000..0daa4fb59 --- /dev/null +++ b/src/components/revisions/RevisionReview.tsx @@ -0,0 +1,148 @@ +import { Component, createSignal, Show } from 'solid-js'; +import type { RevisionData } from '../../lib/revisions/types'; + +export interface RevisionReviewProps { + revision: RevisionData; + onAccept: (revisionId: number) => void; + onReject: (revisionId: number, reason?: string) => void; + onCompareWithBase?: (revisionId: number) => void; +} + +export const RevisionReview: Component = (props) => { + const [showRejectDialog, setShowRejectDialog] = createSignal(false); + const [rejectReason, setRejectReason] = createSignal(''); + const [isProcessing, setIsProcessing] = createSignal(false); + + const handleAccept = () => { + setIsProcessing(true); + props.onAccept(props.revision.id); + setIsProcessing(false); + }; + + const handleReject = () => { + setIsProcessing(true); + props.onReject(props.revision.id, rejectReason() || undefined); + setShowRejectDialog(false); + setRejectReason(''); + setIsProcessing(false); + }; + + const getStatusDisplay = () => { + switch (props.revision.status) { + case 'draft': + return { label: 'Draft', color: '#6b7280' }; + case 'pending_review': + return { label: 'Pending Review', color: '#f59e0b' }; + case 'accepted': + return { label: 'Accepted', color: '#22c55e' }; + case 'rejected': + return { label: 'Rejected', color: '#ef4444' }; + } + }; + + const canReview = props.revision.status === 'pending_review' || props.revision.status === 'draft'; + + return ( +
+
+
+

{props.revision.title}

+
+ v{props.revision.versionNumber} + {props.revision.branchName} + + {getStatusDisplay().label} + +
+
+ + +
{props.revision.summary}
+
+
+ +
+ + + + + + + +
+ + +
+

Reviewed by User #{props.revision.reviewedById}

+ + {(reviewedAt) => ( +

+ {new Date(reviewedAt()).toLocaleString()} +

+ )} +
+
+
+ + +
setShowRejectDialog(false)}> +
e.stopPropagation()}> +

Reject Revision

+

Please provide a reason for rejecting this revision:

+