This commit is contained in:
Harun CAN
2026-03-23 02:44:37 +03:00
parent d95b067fd9
commit 8ce3e36e86
9 changed files with 725 additions and 1 deletions

View File

@@ -26,6 +26,7 @@ import {
LuBrain,
LuChartBar,
LuDownload,
LuHistory,
} from 'react-icons/lu';
import {
useGetProject,
@@ -38,7 +39,7 @@ import {
// Tab Components
import ResearchTab from './tabs/ResearchTab';
import { BriefTab, CharactersTab, ScriptTab, AnalysisTab } from './tabs';
import { BriefTab, CharactersTab, ScriptTab, AnalysisTab, VersionsTab } from './tabs';
interface ProjectDetailProps {
@@ -192,6 +193,15 @@ export default function ProjectDetail({ projectId }: ProjectDetailProps) {
<LuChartBar />
{t('analysis')}
</Tabs.Trigger>
<Tabs.Trigger value='versions'>
<LuHistory />
Versiyonlar
{project.currentVersionNumber > 0 && (
<Badge ml={2} size='sm'>
{project.currentVersionNumber}
</Badge>
)}
</Tabs.Trigger>
</Tabs.List>
<Box minH='500px'>
@@ -234,6 +244,10 @@ export default function ProjectDetail({ projectId }: ProjectDetailProps) {
isAnalyzing={neuroAnalysis.isPending || youtubeAudit.isPending}
/>
</Tabs.Content>
<Tabs.Content value='versions'>
<VersionsTab project={project} />
</Tabs.Content>
</Box>
</Tabs.Root>
</Container>

View File

@@ -0,0 +1,386 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import {
Box,
Button,
Card,
Flex,
Heading,
HStack,
IconButton,
Text,
VStack,
Badge,
Input,
Textarea,
} from '@chakra-ui/react';
import {
LuHistory,
LuSave,
LuUndo2,
LuTrash2,
LuGitCompare,
LuCheck,
LuClock,
LuBot,
LuUser,
LuChevronDown,
LuChevronUp,
} from 'react-icons/lu';
import {
useVersions,
useCreateSnapshot,
useRestoreVersion,
useDeleteVersion,
} from '@/lib/api/skriptai';
import { toaster } from '@/components/ui/feedback/toaster';
import type { ScriptProject, ScriptVersionSummary } from '@/types/skriptai';
interface VersionsTabProps {
project: ScriptProject;
}
export default function VersionsTab({ project }: VersionsTabProps) {
const t = useTranslations('skriptai');
const { data: versions, isLoading } = useVersions(project.id);
const createSnapshot = useCreateSnapshot(project.id);
const restoreVersion = useRestoreVersion(project.id);
const deleteVersion = useDeleteVersion(project.id);
const [showSaveForm, setShowSaveForm] = useState(false);
const [saveLabel, setSaveLabel] = useState('');
const [saveNote, setSaveNote] = useState('');
const [expandedVersionId, setExpandedVersionId] = useState<string | null>(null);
const handleSave = async () => {
await createSnapshot.mutateAsync({
label: saveLabel || undefined,
changeNote: saveNote || undefined,
});
setSaveLabel('');
setSaveNote('');
setShowSaveForm(false);
toaster.create({
title: 'Versiyon kaydedildi',
type: 'success',
});
};
const handleRestore = async (versionId: string, versionNumber: number) => {
if (!confirm(`v${versionNumber} versiyonuna geri dönmek istediğinize emin misiniz? Mevcut durum otomatik olarak kaydedilecektir.`)) {
return;
}
await restoreVersion.mutateAsync(versionId);
toaster.create({
title: `v${versionNumber} versiyonuna geri dönüldü`,
type: 'success',
});
};
const handleDelete = async (versionId: string, versionNumber: number) => {
if (!confirm(`v${versionNumber} versiyonunu silmek istediğinize emin misiniz?`)) {
return;
}
await deleteVersion.mutateAsync(versionId);
toaster.create({
title: `v${versionNumber} silindi`,
type: 'info',
});
};
const getGeneratedByIcon = (generatedBy: string) => {
switch (generatedBy) {
case 'AI':
return <LuBot />;
case 'USER':
return <LuUser />;
default:
return <LuClock />;
}
};
const getGeneratedByColor = (generatedBy: string) => {
switch (generatedBy) {
case 'AI':
return 'purple';
case 'USER':
return 'blue';
default:
return 'gray';
}
};
const getGeneratedByLabel = (generatedBy: string) => {
switch (generatedBy) {
case 'AI':
return 'AI Üretimi';
case 'USER':
return 'Manuel Kayıt';
default:
return 'Otomatik Kayıt';
}
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<VStack align='stretch' gap={6}>
{/* Header */}
<Flex justify='space-between' align='center' flexWrap='wrap' gap={4}>
<HStack gap={3}>
<LuHistory size={20} />
<Heading size='md'>Versiyon Geçmişi</Heading>
<Badge colorPalette='blue'>
{versions?.length || 0} versiyon
</Badge>
<Badge colorPalette='green'>
Şu an: v{project.currentVersionNumber}
</Badge>
</HStack>
<Button
colorPalette='blue'
onClick={() => setShowSaveForm(!showSaveForm)}
disabled={!project.segments || project.segments.length === 0}
>
<LuSave />
Mevcut Durumu Kaydet
</Button>
</Flex>
{/* Save Form */}
{showSaveForm && (
<Card.Root p={4} borderColor='blue.200' borderWidth='2px'>
<VStack align='stretch' gap={3}>
<Text fontWeight='bold' fontSize='sm'>
💾 Yeni Versiyon Kaydet
</Text>
<Input
placeholder='Versiyon etiketi (ör. "Final Taslak", "Sponsor Revizyonu")'
value={saveLabel}
onChange={(e) => setSaveLabel(e.target.value)}
size='sm'
/>
<Textarea
placeholder='Değişiklik notu (isteğe bağlı)'
value={saveNote}
onChange={(e) => setSaveNote(e.target.value)}
rows={2}
size='sm'
/>
<HStack justify='end'>
<Button
size='sm'
variant='ghost'
onClick={() => setShowSaveForm(false)}
>
İptal
</Button>
<Button
size='sm'
colorPalette='green'
onClick={handleSave}
loading={createSnapshot.isPending}
>
<LuCheck />
Kaydet
</Button>
</HStack>
</VStack>
</Card.Root>
)}
{/* No segments warning */}
{(!project.segments || project.segments.length === 0) && (
<Card.Root p={6}>
<VStack>
<Text color='gray.500'>
Henüz script üretilmemiş. Önce bir script oluşturun, ardından versiyonları burada takip edebilirsiniz.
</Text>
</VStack>
</Card.Root>
)}
{/* Version List */}
{isLoading ? (
<Card.Root p={6}>
<Text color='gray.500'>Versiyonlar yükleniyor...</Text>
</Card.Root>
) : versions && versions.length > 0 ? (
<VStack align='stretch' gap={3}>
{versions.map((version) => (
<VersionCard
key={version.id}
version={version}
isExpanded={expandedVersionId === version.id}
onToggle={() =>
setExpandedVersionId(
expandedVersionId === version.id
? null
: version.id,
)
}
onRestore={() =>
handleRestore(version.id, version.versionNumber)
}
onDelete={() =>
handleDelete(version.id, version.versionNumber)
}
isRestoring={restoreVersion.isPending}
isDeleting={deleteVersion.isPending}
getGeneratedByIcon={getGeneratedByIcon}
getGeneratedByColor={getGeneratedByColor}
getGeneratedByLabel={getGeneratedByLabel}
formatDate={formatDate}
/>
))}
</VStack>
) : (
project.segments &&
project.segments.length > 0 && (
<Card.Root p={6}>
<VStack>
<Text color='gray.500'>
Henüz kayıtlı versiyon yok. "Mevcut Durumu Kaydet" butonuna tıklayarak ilk versiyonu oluşturun.
</Text>
<Text color='gray.400' fontSize='sm'>
Script yeniden üretildiğinde versiyonlar otomatik olarak kaydedilir.
</Text>
</VStack>
</Card.Root>
)
)}
</VStack>
);
}
// ========== Version Card ==========
interface VersionCardProps {
version: ScriptVersionSummary;
isExpanded: boolean;
onToggle: () => void;
onRestore: () => void;
onDelete: () => void;
isRestoring: boolean;
isDeleting: boolean;
getGeneratedByIcon: (g: string) => React.ReactNode;
getGeneratedByColor: (g: string) => string;
getGeneratedByLabel: (g: string) => string;
formatDate: (d: string) => string;
}
function VersionCard({
version,
isExpanded,
onToggle,
onRestore,
onDelete,
isRestoring,
isDeleting,
getGeneratedByIcon,
getGeneratedByColor,
getGeneratedByLabel,
formatDate,
}: VersionCardProps) {
return (
<Card.Root
_hover={{ shadow: 'md', borderColor: 'blue.200' }}
transition='all 0.2s'
cursor='pointer'
onClick={onToggle}
>
<Card.Header py={3} px={4}>
<Flex justify='space-between' align='center'>
<HStack gap={3}>
<Badge
colorPalette={getGeneratedByColor(version.generatedBy)}
variant='subtle'
px={2}
py={1}
>
{getGeneratedByIcon(version.generatedBy)}
<Text ml={1} fontSize='xs'>
v{version.versionNumber}
</Text>
</Badge>
<VStack align='start' gap={0}>
<Text fontWeight='semibold' fontSize='sm'>
{version.label || `Versiyon ${version.versionNumber}`}
</Text>
<Text fontSize='xs' color='gray.500'>
{formatDate(version.createdAt)} {' '}
{getGeneratedByLabel(version.generatedBy)}
</Text>
</VStack>
</HStack>
<HStack gap={2}>
<Badge colorPalette='gray' size='sm'>
{version.segmentCount} bölüm
</Badge>
<Badge colorPalette='gray' size='sm'>
~{version.totalWords} kelime
</Badge>
{isExpanded ? <LuChevronUp /> : <LuChevronDown />}
</HStack>
</Flex>
</Card.Header>
{isExpanded && (
<Card.Body pt={0} px={4} pb={4}>
<VStack align='stretch' gap={3}>
{version.changeNote && (
<Box
bg='gray.50'
_dark={{ bg: 'whiteAlpha.100' }}
p={3}
borderRadius='md'
>
<Text fontSize='xs' fontWeight='bold' color='gray.500' mb={1}>
📝 Değişiklik Notu
</Text>
<Text fontSize='sm'>{version.changeNote}</Text>
</Box>
)}
<Flex gap={2} justify='end' onClick={(e) => e.stopPropagation()}>
<Button
size='xs'
colorPalette='blue'
variant='outline'
onClick={onRestore}
loading={isRestoring}
>
<LuUndo2 />
Bu Versiyona Geri Dön
</Button>
<IconButton
aria-label='Versiyonu sil'
size='xs'
colorPalette='red'
variant='ghost'
onClick={onDelete}
loading={isDeleting}
>
<LuTrash2 />
</IconButton>
</Flex>
</VStack>
</Card.Body>
)}
</Card.Root>
);
}

View File

@@ -3,3 +3,4 @@ export { default as BriefTab } from './BriefTab';
export { default as CharactersTab } from './CharactersTab';
export { default as ScriptTab } from './ScriptTab';
export { default as AnalysisTab } from './AnalysisTab';
export { default as VersionsTab } from './VersionsTab';

View File

@@ -33,6 +33,8 @@ export {
useDeleteSegment,
useReorderSegments,
useGenerateSegmentImage,
useRegenerateSegment,
useRegeneratePartial,
ScriptsQueryKeys,
} from './scripts/use-hooks';
@@ -64,6 +66,20 @@ export {
AnalysisQueryKeys,
} from './analysis/use-hooks';
// Services - Versions
export { versionsService } from './versions/service';
// Hooks - Versions
export {
useVersions,
useVersion,
useCreateSnapshot,
useRestoreVersion,
useDeleteVersion,
useCompareVersions,
VersionsQueryKeys,
} from './versions/use-hooks';
// Types - Projects
export type {
CreateProjectDto,

View File

@@ -80,4 +80,19 @@ export const scriptsService = {
method: 'post',
});
},
regenerateSegment: (segmentId: string) => {
return apiRequest<ApiResponse<ScriptSegment>>({
url: `/skriptai/scripts/segments/${segmentId}/regenerate`,
client: 'skriptai',
method: 'post',
});
},
regeneratePartial: (projectId: string, segmentIds: string[]) => {
return apiRequest<ApiResponse<ScriptSegment[]>>({
url: '/skriptai/scripts/regenerate-partial',
client: 'skriptai',
method: 'post',
data: { projectId, segmentIds },
});
},
};

View File

@@ -134,3 +134,38 @@ export function useGenerateSegmentImage(projectId: string) {
return { data: data?.data, ...rest };
}
export function useRegenerateSegment(projectId: string) {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<ApiResponse<ScriptSegment>, Error, string>({
mutationFn: (segmentId) => scriptsService.regenerateSegment(segmentId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
},
});
return { data: data?.data, ...rest };
}
export function useRegeneratePartial(projectId: string) {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<
ApiResponse<ScriptSegment[]>,
Error,
string[]
>({
mutationFn: (segmentIds) =>
scriptsService.regeneratePartial(projectId, segmentIds),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
},
});
return { data: data?.data, ...rest };
}

View File

@@ -0,0 +1,78 @@
import { apiRequest } from '@/lib/api/api-service';
import { ApiResponse } from '@/types/api-response';
import type {
ScriptVersionSummary,
ScriptVersion,
ScriptProject,
VersionComparison,
} from '@/types/skriptai';
/**
* Versions Service - SkriptAI
* Matches Backend: /api/skriptai/projects/:projectId/versions/*
*/
const list = (projectId: string) => {
return apiRequest<ApiResponse<ScriptVersionSummary[]>>({
url: `/skriptai/projects/${projectId}/versions`,
client: 'skriptai',
method: 'get',
});
};
const getOne = (projectId: string, versionId: string) => {
return apiRequest<ApiResponse<ScriptVersion>>({
url: `/skriptai/projects/${projectId}/versions/${versionId}`,
client: 'skriptai',
method: 'get',
});
};
const createSnapshot = (
projectId: string,
data: { label?: string; changeNote?: string },
) => {
return apiRequest<ApiResponse<ScriptVersion>>({
url: `/skriptai/projects/${projectId}/versions`,
client: 'skriptai',
method: 'post',
data,
});
};
const restore = (projectId: string, versionId: string) => {
return apiRequest<ApiResponse<ScriptProject>>({
url: `/skriptai/projects/${projectId}/versions/${versionId}/restore`,
client: 'skriptai',
method: 'post',
});
};
const remove = (projectId: string, versionId: string) => {
return apiRequest<ApiResponse<{ deleted: boolean; versionNumber: number }>>({
url: `/skriptai/projects/${projectId}/versions/${versionId}`,
client: 'skriptai',
method: 'delete',
});
};
const compare = (
projectId: string,
versionAId: string,
versionBId: string,
) => {
return apiRequest<ApiResponse<VersionComparison>>({
url: `/skriptai/projects/${projectId}/versions/compare?versionA=${versionAId}&versionB=${versionBId}`,
client: 'skriptai',
method: 'get',
});
};
export const versionsService = {
list,
getOne,
createSnapshot,
restore,
remove,
compare,
};

View File

@@ -0,0 +1,136 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ApiResponse } from '@/types/api-response';
import { versionsService } from './service';
import type {
ScriptVersionSummary,
ScriptVersion,
ScriptProject,
VersionComparison,
} from '@/types/skriptai';
import { ProjectsQueryKeys } from '../projects/use-hooks';
export const VersionsQueryKeys = {
all: ['skriptai', 'versions'] as const,
list: (projectId: string) =>
['skriptai', 'versions', projectId] as const,
detail: (projectId: string, versionId: string) =>
['skriptai', 'versions', projectId, versionId] as const,
compare: (projectId: string, vA: string, vB: string) =>
['skriptai', 'versions', projectId, 'compare', vA, vB] as const,
};
/**
* Fetch all versions for a project
*/
export function useVersions(projectId: string) {
const { data, ...rest } = useQuery<ApiResponse<ScriptVersionSummary[]>>({
queryKey: VersionsQueryKeys.list(projectId),
queryFn: () => versionsService.list(projectId),
enabled: !!projectId,
});
return { data: data?.data, ...rest };
}
/**
* Fetch a single version with full snapshot data
*/
export function useVersion(projectId: string, versionId: string) {
const { data, ...rest } = useQuery<ApiResponse<ScriptVersion>>({
queryKey: VersionsQueryKeys.detail(projectId, versionId),
queryFn: () => versionsService.getOne(projectId, versionId),
enabled: !!projectId && !!versionId,
});
return { data: data?.data, ...rest };
}
/**
* Create a manual snapshot
*/
export function useCreateSnapshot(projectId: string) {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<
ApiResponse<ScriptVersion>,
Error,
{ label?: string; changeNote?: string }
>({
mutationFn: (snapshotData) =>
versionsService.createSnapshot(projectId, snapshotData),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: VersionsQueryKeys.list(projectId),
});
},
});
return { data: data?.data, ...rest };
}
/**
* Restore to a specific version
*/
export function useRestoreVersion(projectId: string) {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<
ApiResponse<ScriptProject>,
Error,
string
>({
mutationFn: (versionId) =>
versionsService.restore(projectId, versionId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
queryClient.invalidateQueries({
queryKey: VersionsQueryKeys.list(projectId),
});
},
});
return { data: data?.data, ...rest };
}
/**
* Delete a version
*/
export function useDeleteVersion(projectId: string) {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<
ApiResponse<{ deleted: boolean; versionNumber: number }>,
Error,
string
>({
mutationFn: (versionId) =>
versionsService.remove(projectId, versionId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: VersionsQueryKeys.list(projectId),
});
},
});
return { data: data?.data, ...rest };
}
/**
* Compare two versions
*/
export function useCompareVersions(
projectId: string,
versionAId: string,
versionBId: string,
) {
const { data, ...rest } = useQuery<ApiResponse<VersionComparison>>({
queryKey: VersionsQueryKeys.compare(projectId, versionAId, versionBId),
queryFn: () =>
versionsService.compare(projectId, versionAId, versionBId),
enabled: !!projectId && !!versionAId && !!versionBId,
});
return { data: data?.data, ...rest };
}

View File

@@ -65,6 +65,8 @@ export type SourceType =
| 'book'
| 'document';
export type ProjectStatus = 'DRAFT' | 'RESEARCHING' | 'SCRIPTING' | 'ANALYZING' | 'COMPLETED';
export type CharacterRole =
| 'Protagonist'
| 'Antagonist'
@@ -87,6 +89,8 @@ export interface ScriptProject {
logline?: string;
highConcept?: string;
includeInterviews: boolean;
status: ProjectStatus;
currentVersionNumber: number;
seoTitle?: string;
seoDescription?: string;
seoTags: string[];
@@ -269,6 +273,7 @@ export interface ProjectListItem {
topic: string;
contentType: string;
language: string;
status?: ProjectStatus;
createdAt: string;
updatedAt: string;
_count: {
@@ -276,3 +281,41 @@ export interface ProjectListItem {
sources: number;
};
}
// Version History Types
export interface ScriptVersionSummary {
id: string;
versionNumber: number;
label?: string;
generatedBy: 'AI' | 'USER' | 'AUTO_SAVE';
segmentCount: number;
totalWords: number;
changeNote?: string;
createdAt: string;
}
export interface ScriptVersion extends ScriptVersionSummary {
projectId: string;
snapshotData: any[];
seoSnapshot?: {
seoTitle?: string;
seoDescription?: string;
seoTags?: string[];
thumbnailIdeas?: string[];
};
}
export interface VersionDiff {
index: number;
type: 'added' | 'removed' | 'modified';
segmentType?: string;
narratorScript?: { before: string | null; after: string | null };
visualDescription?: { before: string | null; after: string | null };
}
export interface VersionComparison {
versionA: { id: string; versionNumber: number; label?: string; createdAt: string };
versionB: { id: string; versionNumber: number; label?: string; createdAt: string };
totalDiffs: number;
diffs: VersionDiff[];
}