This commit is contained in:
Harun CAN
2026-03-23 03:07:13 +03:00
parent 4da4831256
commit a54e818745
6 changed files with 432 additions and 21 deletions

107
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.3",
"yup": "^1.7.1"
},
"devDependencies": {
@@ -147,7 +148,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -557,7 +557,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ark-ui/react": "^5.29.1",
"@emotion/is-prop-valid": "^1.4.0",
@@ -693,7 +692,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1986,7 +1984,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -2626,6 +2623,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2841,7 +2844,6 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -2880,7 +2882,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.16"
},
@@ -3032,7 +3033,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3092,7 +3092,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -4520,7 +4519,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4886,7 +4884,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -4993,7 +4990,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5494,6 +5490,49 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -5746,7 +5785,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5948,7 +5986,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7894,7 +7931,6 @@
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "16.0.0",
"@swc/helpers": "0.5.15",
@@ -8558,7 +8594,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8682,7 +8717,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8692,7 +8726,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8705,7 +8738,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.0.tgz",
"integrity": "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9239,6 +9271,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -9618,7 +9678,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9799,7 +9858,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10208,6 +10266,14 @@
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -10255,7 +10321,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -26,6 +26,7 @@
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.3",
"yup": "^1.7.1"
},
"devDependencies": {

166
src/hooks/useSocket.ts Normal file
View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useQueryClient } from '@tanstack/react-query';
import { ProjectsQueryKeys } from '@/lib/api/skriptai';
// Event types (mirrors backend ws-events.ts)
export const WS_EVENTS = {
JOB_PROGRESS: 'job:progress',
JOB_COMPLETED: 'job:completed',
JOB_FAILED: 'job:failed',
SEGMENT_GENERATED: 'segment:generated',
SEGMENT_UPDATED: 'segment:updated',
VERSION_CREATED: 'version:created',
VERSION_RESTORED: 'version:restored',
PROJECT_STATUS_CHANGED: 'project:status-changed',
} as const;
export interface JobProgressPayload {
jobId: string;
jobType: string;
projectId: string;
step: number;
totalSteps: number;
message: string;
percentage: number;
}
export interface JobCompletedPayload {
jobId: string;
jobType: string;
projectId: string;
result?: any;
}
export interface JobFailedPayload {
jobId: string;
jobType: string;
projectId: string;
reason: string;
}
interface UseSocketOptions {
projectId: string;
onProgress?: (payload: JobProgressPayload) => void;
onCompleted?: (payload: JobCompletedPayload) => void;
onFailed?: (payload: JobFailedPayload) => void;
autoInvalidate?: boolean;
}
/**
* useSocket — Real-time WebSocket hook for SkriptAI
*
* Connects to the Socket.IO namespace, joins a project room,
* and listens for job progress, completion, and failure events.
*
* Usage:
* ```tsx
* const { isConnected, latestProgress } = useSocket({
* projectId: 'abc123',
* onProgress: (p) => console.log(p.message),
* onCompleted: () => refetch(),
* });
* ```
*/
export function useSocket({
projectId,
onProgress,
onCompleted,
onFailed,
autoInvalidate = true,
}: UseSocketOptions) {
const socketRef = useRef<Socket | null>(null);
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
const [latestProgress, setLatestProgress] =
useState<JobProgressPayload | null>(null);
const connect = useCallback(() => {
if (socketRef.current?.connected) return;
// Determine WS URL from the API base or default
const wsUrl =
process.env.NEXT_PUBLIC_SKRIPTAI_WS_URL ||
process.env.NEXT_PUBLIC_SKRIPTAI_API_URL?.replace(/\/api$/, '') ||
'http://localhost:3001';
const socket = io(`${wsUrl}/skriptai`, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});
socket.on('connect', () => {
setIsConnected(true);
socket.emit('join:project', projectId);
});
socket.on('disconnect', () => {
setIsConnected(false);
});
// Job progress
socket.on(WS_EVENTS.JOB_PROGRESS, (payload: JobProgressPayload) => {
setLatestProgress(payload);
onProgress?.(payload);
});
// Job completed
socket.on(WS_EVENTS.JOB_COMPLETED, (payload: JobCompletedPayload) => {
setLatestProgress(null);
onCompleted?.(payload);
if (autoInvalidate) {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
}
});
// Job failed
socket.on(WS_EVENTS.JOB_FAILED, (payload: JobFailedPayload) => {
setLatestProgress(null);
onFailed?.(payload);
});
// Version events → auto-invalidate
socket.on(WS_EVENTS.VERSION_CREATED, () => {
if (autoInvalidate) {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
}
});
socket.on(WS_EVENTS.VERSION_RESTORED, () => {
if (autoInvalidate) {
queryClient.invalidateQueries({
queryKey: ProjectsQueryKeys.detail(projectId),
});
}
});
socketRef.current = socket;
}, [projectId, onProgress, onCompleted, onFailed, autoInvalidate, queryClient]);
useEffect(() => {
connect();
return () => {
if (socketRef.current) {
socketRef.current.emit('leave:project', projectId);
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
setLatestProgress(null);
}
};
}, [connect, projectId]);
return {
isConnected,
latestProgress,
socket: socketRef.current,
};
}

View File

@@ -80,6 +80,13 @@ export {
VersionsQueryKeys,
} from './versions/use-hooks';
// Services - Jobs
export { jobsService } from './jobs/service';
export type { SubmitJobRequest, SubmitJobResponse, JobStatusResponse, JobResultResponse } from './jobs/service';
// Hooks - Jobs
export { useSubmitJob, useJobPolling } from './jobs/use-hooks';
// Types - Projects
export type {
CreateProjectDto,

View File

@@ -0,0 +1,72 @@
import { apiRequest } from '@/lib/api/api-service';
import { ApiResponse } from '@/types/api-response';
/**
* Jobs Service - SkriptAI
* Matches Backend: /api/skriptai/jobs/*
*/
export interface SubmitJobRequest {
type: string;
payload: Record<string, any>;
}
export interface SubmitJobResponse {
jobId: string;
type: string;
status: string;
}
export interface JobStatusResponse {
jobId: string;
type: string;
status: 'QUEUED' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: {
step: number;
totalSteps: number;
message: string;
percentage: number;
} | null;
createdAt: string;
processedOn: string | null;
finishedOn: string | null;
failedReason: string | null;
}
export interface JobResultResponse {
jobId: string;
status: string;
result: any;
message?: string;
}
const submitJob = (data: SubmitJobRequest) => {
return apiRequest<ApiResponse<SubmitJobResponse>>({
url: '/skriptai/jobs/submit',
client: 'skriptai',
method: 'post',
data,
});
};
const getStatus = (jobId: string) => {
return apiRequest<ApiResponse<JobStatusResponse>>({
url: `/skriptai/jobs/${jobId}/status`,
client: 'skriptai',
method: 'get',
});
};
const getResult = (jobId: string) => {
return apiRequest<ApiResponse<JobResultResponse>>({
url: `/skriptai/jobs/${jobId}/result`,
client: 'skriptai',
method: 'get',
});
};
export const jobsService = {
submitJob,
getStatus,
getResult,
};

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ApiResponse } from '@/types/api-response';
import {
jobsService,
SubmitJobRequest,
SubmitJobResponse,
JobStatusResponse,
} from './service';
/**
* useSubmitJob — Submit a new async job
*/
export function useSubmitJob() {
const { data, ...rest } = useMutation<
ApiResponse<SubmitJobResponse>,
Error,
SubmitJobRequest
>({
mutationFn: (jobData) => jobsService.submitJob(jobData),
});
return { data: data?.data, ...rest };
}
/**
* useJobPolling — Poll a job for status updates
*
* @param jobId - Job ID to poll (null to disable)
* @param intervalMs - Polling interval in ms (default: 2000)
* @param onComplete - Callback when job completes
* @param onFail - Callback when job fails
*/
export function useJobPolling(
jobId: string | null,
intervalMs: number = 2000,
onComplete?: (result: any) => void,
onFail?: (reason: string) => void,
) {
const [status, setStatus] = useState<JobStatusResponse | null>(null);
const [isPolling, setIsPolling] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsPolling(false);
}, []);
const pollOnce = useCallback(async () => {
if (!jobId) return;
try {
const response = await jobsService.getStatus(jobId);
const jobStatus = response.data;
setStatus(jobStatus);
if (jobStatus.status === 'COMPLETED') {
stopPolling();
if (onComplete) {
const resultResp = await jobsService.getResult(jobId);
onComplete(resultResp.data?.result);
}
} else if (jobStatus.status === 'FAILED') {
stopPolling();
if (onFail) {
onFail(jobStatus.failedReason || 'Unknown error');
}
}
} catch (error) {
// Silently retry on network errors
console.warn('Job polling error:', error);
}
}, [jobId, onComplete, onFail, stopPolling]);
useEffect(() => {
if (!jobId) {
stopPolling();
setStatus(null);
return;
}
setIsPolling(true);
pollOnce(); // Poll immediately
intervalRef.current = setInterval(pollOnce, intervalMs);
return () => {
stopPolling();
};
}, [jobId, intervalMs, pollOnce, stopPolling]);
return {
status,
isPolling,
stopPolling,
progress: status?.progress || null,
};
}