Merge branch 'fe/develop' of https://lab.ssafy.com/s11-s-project/S11P21S002 into fe/feat/admin-review-request

This commit is contained in:
정현조 2024-09-25 03:44:41 +09:00
commit 8aec3342e3
39 changed files with 729 additions and 913 deletions

View File

@ -47,11 +47,11 @@ export async function uploadImageFile(memberId: number, projectId: number, folde
export async function uploadImageFolder(memberId: number, projectId: number, files: File[]) {
const formData = new FormData();
files.forEach((file) => {
formData.append('folderZip', file);
formData.append('imageList', file);
});
return api
.post(`/projects/${projectId}/folders/${0}/images/zip`, formData, {
.post(`/projects/${projectId}/folders/${0}/images/file`, formData, {
params: { memberId },
})
.then(({ data }) => data);

View File

@ -1,5 +1,5 @@
import api from '@/api/axiosConfig';
import { ProjectListResponse, ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
import { ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
export async function getProjectList(
workspaceId: number,
@ -8,7 +8,7 @@ export async function getProjectList(
limit: number = 50
) {
return api
.get<ProjectListResponse>(`/workspaces/${workspaceId}/projects`, {
.get<ProjectResponse[]>(`/workspaces/${workspaceId}/projects`, {
params: {
memberId,
lastProjectId,

View File

@ -1,21 +1,22 @@
import { Link, useLocation, useParams } from 'react-router-dom';
import { cn } from '@/lib/utils';
export default function AdminMenuSidebar() {
const location = useLocation();
const { workspaceId } = useParams<{ workspaceId: string }>();
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
const menuItems = [
{
label: '리뷰',
path: `/admin/${workspaceId}/reviews`,
path: projectId ? `/admin/${workspaceId}/reviews/${projectId}` : `/admin/${workspaceId}/reviews`,
},
{
label: '멤버 관리',
path: `/admin/${workspaceId}/members`,
path: projectId ? `/admin/${workspaceId}/members/${projectId}` : `/admin/${workspaceId}/members`,
},
{
label: '모델 관리',
path: `/admin/${workspaceId}/models`,
path: projectId ? `/admin/${workspaceId}/models/${projectId}` : `/admin/${workspaceId}/models`,
},
];

View File

@ -1,5 +1,5 @@
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Link, useLocation, useParams } from 'react-router-dom';
import { SquarePen } from 'lucide-react';
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
import useCreateProjectQuery from '@/queries/projects/useCreateProjectQuery';
@ -11,7 +11,6 @@ import { cn } from '@/lib/utils';
export default function AdminProjectSidebar(): JSX.Element {
const location = useLocation();
const navigate = useNavigate();
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0;
@ -19,8 +18,7 @@ export default function AdminProjectSidebar(): JSX.Element {
const { data: workspaceData } = useWorkspaceQuery(Number(workspaceId), memberId);
const workspaceTitle = workspaceData?.title || `Workspace-${workspaceId}`;
const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId);
const projects = projectsResponse?.workspaceResponses ?? [];
const { data: projects } = useProjectListQuery(Number(workspaceId), memberId);
const createProject = useCreateProjectQuery();
@ -32,13 +30,6 @@ export default function AdminProjectSidebar(): JSX.Element {
});
};
const handleHeaderClick = () => {
navigate({
pathname: location.pathname,
search: '',
});
};
const getNewPath = (newProjectId: string) => {
if (location.pathname.includes('reviews')) {
return `/admin/${workspaceId}/reviews/${newProjectId}`;
@ -52,6 +43,9 @@ export default function AdminProjectSidebar(): JSX.Element {
return location.pathname;
};
const basePath = location.pathname.split('/')[3];
const basePathWithoutProjectId = `/admin/${workspaceId}/${basePath}`;
return (
<>
<ResizablePanel
@ -61,12 +55,12 @@ export default function AdminProjectSidebar(): JSX.Element {
className="flex h-full flex-col border-r border-gray-200 bg-gray-100"
>
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
<h1
className="heading w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
onClick={handleHeaderClick}
<Link
to={basePathWithoutProjectId}
className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
>
{workspaceTitle}
</h1>
</Link>
<button className="p-2">
<SquarePen size={16} />
</button>

View File

@ -1,6 +1,5 @@
import { Label } from '@/types';
import Konva from 'konva';
import { useState } from 'react';
import { Line } from 'react-konva';
import PolygonTransformer from './PolygonTransformer';
@ -8,21 +7,25 @@ export default function LabelPolygon({
isSelected,
onSelect,
info,
setLabel,
stage,
dragLayer,
}: {
isSelected: boolean;
onSelect: () => void;
info: Label;
setLabel: (coordinate: [number, number][]) => void;
stage: Konva.Stage;
dragLayer: Konva.Layer;
}) {
const [coordinates, setCoordinates] = useState<Array<[number, number]>>(info.coordinates);
const handleChange = (coordinates: [number, number][]) => {
setLabel(coordinates);
};
return (
<>
<Line
points={coordinates.flat()}
points={info.coordinates.flat()}
stroke={info.color}
strokeWidth={1}
onMouseDown={onSelect}
@ -33,8 +36,8 @@ export default function LabelPolygon({
/>
{isSelected && (
<PolygonTransformer
coordinates={coordinates}
setCoordinates={setCoordinates}
coordinates={info.coordinates}
setCoordinates={handleChange}
stage={stage}
dragLayer={dragLayer}
/>

View File

@ -119,10 +119,10 @@ export default function ImageCanvas() {
setPolygonPoints([]);
if (polygonPoints.length < 4) return;
const color = Math.floor(Math.random() * 65535)
const color = Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0');
const id = labels.length + 1;
const id = labels.length;
addLabel({
id: id,
name: 'label',
@ -146,7 +146,7 @@ export default function ImageCanvas() {
return;
}
setRectPoints([]);
const color = Math.floor(Math.random() * 65535)
const color = Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0');
const id = labels.length;
@ -289,6 +289,7 @@ export default function ImageCanvas() {
isSelected={label.id === selectedLabelId}
onSelect={() => setSelectedLabelId(label.id)}
info={label}
setLabel={setLabel(label.id)}
stage={stageRef.current as Konva.Stage}
dragLayer={dragLayerRef.current as Konva.Layer}
/>

View File

@ -9,12 +9,10 @@ import SearchInput from '../ui/search-input';
import useSearchMembersByEmailQuery from '@/queries/members/useSearchMembersByEmailQuery';
import debounce from 'lodash/debounce';
type PrivilegeType = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
const privilegeTypes: readonly ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'];
type PrivilegeType = 'MANAGER' | 'EDITOR' | 'VIEWER'; // ADMIN을 제외
const privilegeTypes: readonly ['MANAGER', 'EDITOR', 'VIEWER'] = ['MANAGER', 'EDITOR', 'VIEWER']; // ADMIN을 제외
const privilegeTypeToStr: { [key in PrivilegeType]: string } = {
ADMIN: '관리자',
MANAGER: '매니저',
EDITOR: '에디터',
VIEWER: '뷰어',

View File

@ -0,0 +1,55 @@
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import ModelBarChart from '@/components/ModelBarChart';
interface EvaluationTabProps {
selectedModel: string | null;
setSelectedModel: (model: string | null) => void;
}
export default function EvaluationTab({ selectedModel, setSelectedModel }: EvaluationTabProps) {
return (
<div>
<div className="mb-4">
<Label htmlFor="select-model"> </Label>
<Select onValueChange={setSelectedModel}>
<SelectTrigger id="select-model">
<SelectValue placeholder="모델을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="genesis">Genesis</SelectItem>
<SelectItem value="explorer">Explorer</SelectItem>
<SelectItem value="quantum">Quantum</SelectItem>
</SelectContent>
</Select>
</div>
{selectedModel && (
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-6">
<ModelBarChart
data={[
{ name: 'precision', value: 0.734, fill: 'var(--color-precision)' },
{ name: 'recall', value: 0.75, fill: 'var(--color-recall)' },
{ name: 'mAP50', value: 0.995, fill: 'var(--color-map50)' },
{ name: 'mAP50_95', value: 0.97, fill: 'var(--color-map50-95)' },
{ name: 'fitness', value: 0.973, fill: 'var(--color-fitness)' },
]}
/>
</div>
<div className="flex flex-col justify-center">
<LabelingPreview />
</div>
</div>
)}
</div>
);
}
function LabelingPreview() {
return (
<div className="flex items-center justify-center rounded-lg border bg-white p-4">
<p> </p>
</div>
);
}

View File

@ -0,0 +1,189 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import useProjectModelsQuery from '@/queries/models/useProjectModelsQuery';
import { useState } from 'react';
interface SettingsFormProps {
projectId: string | null; // projectId를 프랍으로 받음
onSubmit?: (data: SettingsFormData) => void;
}
export interface SettingsFormData {
projectId: number | null;
selectedModel: string | null;
ratio: number;
epochs: number;
batchSize: number;
optimizer: string;
lr0: number;
lrf: number;
}
export default function SettingsForm({ projectId, onSubmit }: SettingsFormProps) {
const numericProjectId = projectId ? parseInt(projectId, 10) : null;
const { data: models } = useProjectModelsQuery(numericProjectId ?? 0);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [ratio, setRatio] = useState<number>(0.8);
const [epochs, setEpochs] = useState<number>(50);
const [batchSize, setBatchSize] = useState<number>(32);
const [optimizer, setOptimizer] = useState<string>('SGD');
const [lr0, setLr0] = useState<number>(0.01);
const [lrf, setLrf] = useState<number>(0.001);
const handleSubmit = () => {
if (onSubmit) {
onSubmit({
projectId: numericProjectId,
selectedModel,
ratio,
epochs,
batchSize,
optimizer,
lr0,
lrf,
});
}
};
return (
<form
className="grid w-full gap-6"
onSubmit={handleSubmit}
>
<fieldset className="grid gap-6 rounded-lg border p-4">
<legend className="-ml-1 px-1 text-sm font-medium"> </legend>
{/* 모델 선택 */}
<div className="grid gap-3">
<Label htmlFor="model"> </Label>
<Select onValueChange={setSelectedModel}>
<SelectTrigger id="model">
<SelectValue placeholder="모델을 선택하세요" />
</SelectTrigger>
<SelectContent>
{models?.map((model) => (
<SelectItem
key={model.id}
value={model.name}
>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 훈련/검증 비율 및 학습 파라미터 */}
<div className="grid grid-cols-2 gap-4">
<InputWithLabel
label="훈련/검증 비율"
placeholder="예: 0.8 (80% 훈련, 20% 검증)"
id="ratio"
value={ratio}
onChange={(e) => setRatio(parseFloat(e.target.value))}
/>
<InputWithLabel
label="에포크 수"
placeholder="예: 50 (총 반복 횟수)"
id="epochs"
value={epochs}
onChange={(e) => setEpochs(parseInt(e.target.value, 10))}
/>
<InputWithLabel
label="Batch 크기"
placeholder="예: 32 (한번에 처리할 샘플 수)"
id="batch"
value={batchSize}
onChange={(e) => setBatchSize(parseInt(e.target.value, 10))}
/>
<SelectWithLabel
label="옵티마이저"
id="optimizer"
options={['SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp']}
value={optimizer}
onChange={setOptimizer}
placeholder="옵티마이저 선택"
/>
<InputWithLabel
label="학습률(LR0)"
placeholder="예: 0.01 (초기 학습률)"
id="lr0"
value={lr0}
onChange={(e) => setLr0(parseFloat(e.target.value))}
/>
<InputWithLabel
label="최종 학습률(LRF)"
placeholder="예: 0.001 (최종 학습률)"
id="lrf"
value={lrf}
onChange={(e) => setLrf(parseFloat(e.target.value))}
/>
</div>
<button
type="submit"
className="btn-primary"
>
</button>
</fieldset>
</form>
);
}
interface InputWithLabelProps {
label: string;
id: string;
placeholder: string;
value: number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
function InputWithLabel({ label, id, placeholder, value, onChange }: InputWithLabelProps) {
return (
<div className="grid gap-3">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="number"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
</div>
);
}
interface SelectWithLabelProps {
label: string;
id: string;
options: string[];
placeholder: string;
value: string;
onChange: (value: string) => void;
}
function SelectWithLabel({ label, id, options, placeholder, onChange }: SelectWithLabelProps) {
return (
<div className="grid gap-3">
<Label htmlFor={id}>{label}</Label>
<Select onValueChange={onChange}>
<SelectTrigger id={id}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option}
value={option}
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Button } from '@/components/ui/button';
import ModelLineChart from '@/components/ModelLineChart';
import SettingsForm from './SettingsForm';
interface TrainingTabProps {
training: boolean;
handleTrainingToggle: () => void;
trainingDataList: {
epoch: number;
box_loss: number;
cls_loss: number;
dfl_loss: number;
fitness: number;
}[];
projectId: string | null; // projectId를 프랍으로 받음
}
export default function TrainingTab({ training, handleTrainingToggle, trainingDataList, projectId }: TrainingTabProps) {
return (
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-6">
<SettingsForm projectId={projectId} />
<Button
variant={training ? 'destructive' : 'outlinePrimary'}
size="lg"
onClick={handleTrainingToggle}
>
{training ? '학습 중단' : '학습 시작'}
</Button>
</div>
<div className="flex flex-col justify-center">
<ModelLineChart
data={trainingDataList.map((data) => ({
epoch: data.epoch.toString(),
loss1: data.box_loss,
loss2: data.cls_loss,
loss3: data.dfl_loss,
fitness: data.fitness,
}))}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import useTrainWebSocket from '@/hooks/useTrainPolling';
import useTrainStore from '@/stores/useTrainStore';
import TrainingTab from './TrainingTab';
import EvaluationTab from './EvaluationTab';
export default function ModelManage() {
const { projectId } = useParams<{ projectId?: string }>();
const [training, setTraining] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const numericProjectId = projectId ?? null;
useTrainWebSocket(training, numericProjectId);
const { trainingDataList } = useTrainStore((state) => ({
trainingDataList: numericProjectId ? state.trainingDataByProject[numericProjectId] || [] : [],
}));
const handleTrainingToggle = () => {
setTraining((prev) => !prev);
};
return (
<div className="grid h-screen w-full">
<div className="flex flex-col">
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
<h1 className="text-xl font-semibold"> </h1>
</header>
<main className="grid flex-1 gap-4 overflow-auto p-4">
<Tabs
defaultValue="train"
className="w-full"
>
<TabsList>
<TabsTrigger value="train"> </TabsTrigger>
<TabsTrigger value="results"> </TabsTrigger>
</TabsList>
{/* 학습 탭 */}
<TabsContent value="train">
<TrainingTab
training={training}
handleTrainingToggle={handleTrainingToggle}
trainingDataList={trainingDataList}
projectId={numericProjectId}
/>
</TabsContent>
{/* 평가 탭 */}
<TabsContent value="results">
<EvaluationTab
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
/>
</TabsContent>
</Tabs>
</main>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import useHandleOAuthCallback from '@/hooks/useOAuthCallbackHooks';
import useHandleOAuthCallback from '@/hooks/useOAuthCallback';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import useAuthStore from '@/stores/useAuthStore';

View File

@ -12,17 +12,34 @@ import React from 'react';
import ImageUploadFolderForm from '../ImageUploadFolderModal/ImageUploadFolderForm';
import ImageUploadZipForm from '../ImageUploadZipModal/ImageUploadZipForm';
export default function WorkspaceDropdownMenu({ projectId, folderId }: { projectId: number; folderId: number }) {
export default function WorkspaceDropdownMenu({
projectId,
folderId,
refetch,
}: {
projectId: number;
folderId: number;
refetch: () => void;
}) {
const [isOpenUploadFile, setIsOpenUploadFile] = React.useState(false);
const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState(false);
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState(false);
const handleOpenUploadFile = () => setIsOpenUploadFile(true);
const handleCloseUploadFile = () => setIsOpenUploadFile(false);
const handleCloseUploadFile = () => {
refetch();
setIsOpenUploadFile(false);
};
const handleOpenUploadFolder = () => setIsOpenUploadFolder(true);
const handleCloseUploadFolder = () => setIsOpenUploadFolder(false);
const handleCloseUploadFolder = () => {
refetch();
setIsOpenUploadFolder(false);
};
const handleOpenUploadZip = () => setIsOpenUploadZip(true);
const handleCloseUploadZip = () => setIsOpenUploadZip(false);
const handleCloseUploadZip = () => {
refetch();
setIsOpenUploadZip(false);
};
return (
<>
@ -40,7 +57,7 @@ export default function WorkspaceDropdownMenu({ projectId, folderId }: { project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleOpenUploadFile}> </DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadFolder}> </DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadFolder}> ()</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenUploadZip}> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -66,7 +83,7 @@ export default function WorkspaceDropdownMenu({ projectId, folderId }: { project
>
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title="폴더 업로드" />
<DialogHeader title="폴더 업로드 (임시)" />
<ImageUploadFolderForm
onClose={handleCloseUploadFolder}
projectId={projectId}

View File

@ -30,7 +30,7 @@ export default function WorkspaceLayout() {
useEffect(() => {
if (!projectListData) return;
const projects = projectListData.workspaceResponses.map(
const projects = projectListData.map(
(project): Project => ({
id: project.id,
name: project.title,

View File

@ -36,20 +36,19 @@ export default function ProjectDirectoryItem({
<button className="flex items-center">
<ChevronRight
size={16}
className={`stroke-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
className={cn('stroke-gray-500 transition-transform', isExpanded ? 'rotate-90' : '')}
/>
</button>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item.title}</span>
</div>
{isExpanded && (
<div className="caption flex flex-col">
{
<div className={cn('caption flex flex-col', isExpanded ? '' : 'hidden')}>
{folderData.children.map((item) => (
<ProjectDirectoryItem
key={`${projectId}-${item.title}`}
projectId={projectId}
item={item}
depth={depth + 1}
initialExpanded={true}
/>
))}
{folderData.images.map((item) => (
@ -61,7 +60,7 @@ export default function ProjectDirectoryItem({
/>
))}
</div>
)}
}
</>
);
}

View File

@ -11,7 +11,7 @@ import WorkspaceDropdownMenu from '../WorkspaceDropdownMenu';
export default function ProjectStructure({ project }: { project: Project }) {
const setProject = useCanvasStore((state) => state.setProject);
const image = useCanvasStore((state) => state.image);
const { data: folderData } = useFolderQuery(project.id.toString(), 0);
const { data: folderData, refetch } = useFolderQuery(project.id.toString(), 0);
useEffect(() => {
setProject(project);
@ -27,6 +27,7 @@ export default function ProjectStructure({ project }: { project: Project }) {
<WorkspaceDropdownMenu
projectId={project.id}
folderId={0}
refetch={refetch}
/>
</header>
{folderData.children.length === 0 && folderData.images.length === 0 ? (

View File

@ -5,6 +5,7 @@ import { Project } from '@/types';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../ui/select';
import useCanvasStore from '@/stores/useCanvasStore';
import { webPath } from '@/router';
import { Suspense } from 'react';
export default function WorkspaceSidebar({ workspaceName, projects }: { workspaceName: string; projects: Project[] }) {
const { projectId: selectedProjectId } = useParams<{ projectId: string }>();
@ -48,7 +49,7 @@ export default function WorkspaceSidebar({ workspaceName, projects }: { workspac
</SelectContent>
</Select>
</div>
{selectedProject && <ProjectStructure project={selectedProject} />}
<Suspense fallback={<div></div>}>{selectedProject && <ProjectStructure project={selectedProject} />}</Suspense>
</ResizablePanel>
<ResizableHandle className="bg-gray-300" />
</>

View File

@ -1,39 +0,0 @@
// import { useMutation, useQueryClient } from '@tanstack/react-query';
// import useAuthStore from '@/stores/useAuthStore';
// import { reissueToken } from '@/api/authApi';
// import { useEffect } from 'react';
// import useProfileQuery from '@/queries/auth/useProfileQuery';
// export const useReissueToken = () => {
// const queryClient = useQueryClient();
// const { setLoggedIn } = useAuthStore();
// return useMutation({
// mutationFn: reissueToken,
// onSuccess: (data) => {
// setLoggedIn(true, data.accessToken);
// queryClient.invalidateQueries({ queryKey: ['profile'] });
// },
// });
// };
// export const useProfile = () => {
// const { setProfile } = useAuthStore();
// const query = useProfileQuery();
// // TODO: query.data가 변경될 때마다 setProfile을 호출하여 profile 업데이트, useEffect 제거
// useEffect(() => {
// setProfile(query.data);
// }, [query.data, setProfile]);
// return query;
// };
// export const useFetchProfile = () => {
// const { setProfile } = useAuthStore();
// const query = useProfileQuery();
// if (query.data) {
// setProfile(query.data);
// }
// };

View File

@ -0,0 +1,17 @@
import { useMemo } from 'react';
import useAuthStore from '@/stores/useAuthStore';
import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery';
export default function useIsAdmin(projectId: number) {
const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0;
const { data: projectMembers = [] } = useProjectMembersQuery(projectId, memberId);
const isAdminOrManager = useMemo(() => {
const currentMember = projectMembers.find((member) => member.memberId === memberId);
return currentMember?.privilegeType === 'ADMIN';
}, [projectMembers, memberId]);
return isAdminOrManager;
}

View File

@ -1,198 +0,0 @@
// TODO: 훅 재설계
// import { useQuery, UseQueryResult, useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query';
// import { AxiosError } from 'axios';
// import {
// getProject,
// updateProject,
// deleteProject,
// getAllProjects,
// createProject,
// addProjectMember,
// removeProjectMember,
// } from '@/api/projectApi';
// import { BaseResponse, ProjectResponse, ProjectListResponse, CustomError } from '@/types';
// export const useGetProject = (
// projectId: number,
// memberId: number
// ): UseQueryResult<BaseResponse<ProjectResponse>, AxiosError<CustomError>> => {
// return useQuery<BaseResponse<ProjectResponse>, AxiosError<CustomError>>({
// queryKey: ['project', projectId],
// queryFn: () => getProject(projectId, memberId),
// });
// };
// export const useUpdateProject = (): UseMutationResult<
// BaseResponse<ProjectResponse>,
// AxiosError<CustomError>,
// {
// projectId: number;
// memberId: number;
// data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' };
// }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ projectId, memberId, data }) => updateProject(projectId, memberId, data),
// onSuccess: (data) => {
// queryClient.invalidateQueries({ queryKey: ['project', data.data.id] });
// },
// });
// };
// export const useDeleteProject = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { projectId: number; memberId: number }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ projectId, memberId }) => deleteProject(projectId, memberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
// },
// });
// };
// export const useGetAllProjects = (
// workspaceId: number,
// memberId: number,
// options?: { enabled: boolean }
// ): UseQueryResult<BaseResponse<ProjectListResponse>, AxiosError<CustomError>> => {
// return useQuery<BaseResponse<ProjectListResponse>, AxiosError<CustomError>>({
// queryKey: ['projects', workspaceId],
// queryFn: () => getAllProjects(workspaceId, memberId),
// enabled: options?.enabled,
// });
// };
// export const useCreateProject = (): UseMutationResult<
// BaseResponse<ProjectResponse>,
// AxiosError<CustomError>,
// {
// workspaceId: number;
// memberId: number;
// data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' };
// }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ workspaceId, memberId, data }) => createProject(workspaceId, memberId, data),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['projects', variables.workspaceId] });
// },
// });
// };
// export const useAddProjectMember = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { projectId: number; memberId: number; newMemberId: number; privilegeType: string }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ projectId, memberId, newMemberId, privilegeType }) =>
// addProjectMember(projectId, memberId, newMemberId, privilegeType),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
// },
// });
// };
// export const useRemoveProjectMember = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { projectId: number; memberId: number; targetMemberId: number }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
// },
// });
// };
// import { useMutation, useQueryClient } from '@tanstack/react-query';
// import {
// createProject,
// updateProject,
// deleteProject,
// addProjectMember,
// updateProjectMemberPrivilege,
// removeProjectMember,
// } from '@/api/projectApi';
// import { ProjectResponse, ProjectRequest, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
// export const useCreateProject = () => {
// const queryClient = useQueryClient();
// return useMutation<ProjectResponse, Error, { workspaceId: number; memberId: number; data: ProjectRequest }>({
// mutationFn: ({ workspaceId, memberId, data }) => createProject(workspaceId, memberId, data),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['projects', variables.workspaceId] });
// },
// });
// };
// export const useUpdateProject = () => {
// const queryClient = useQueryClient();
// return useMutation<ProjectResponse, Error, { projectId: number; memberId: number; data: ProjectRequest }>({
// mutationFn: ({ projectId, memberId, data }) => updateProject(projectId, memberId, data),
// onSuccess: (data) => {
// queryClient.invalidateQueries({ queryKey: ['project', data.id] });
// },
// });
// };
// export const useDeleteProject = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { projectId: number; memberId: number }>({
// mutationFn: ({ projectId, memberId }) => deleteProject(projectId, memberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] });
// },
// });
// };
// export const useAddProjectMember = () => {
// const queryClient = useQueryClient();
// return useMutation<
// ProjectMemberResponse,
// Error,
// { projectId: number; memberId: number; newMember: ProjectMemberRequest }
// >({
// mutationFn: ({ projectId, memberId, newMember }) => addProjectMember(projectId, memberId, newMember),
// onSuccess: (_, { projectId }) => {
// queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
// },
// });
// };
// // 프로젝트 멤버 권한 수정 훅
// export const useUpdateProjectMemberPrivilege = () => {
// const queryClient = useQueryClient();
// return useMutation<
// ProjectMemberResponse,
// Error,
// { projectId: number; memberId: number; privilegeData: ProjectMemberRequest }
// >({
// mutationFn: ({ projectId, memberId, privilegeData }) =>
// updateProjectMemberPrivilege(projectId, memberId, privilegeData),
// onSuccess: (_, { projectId }) => {
// queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
// },
// });
// };
// // 프로젝트 멤버 삭제 훅
// export const useRemoveProjectMember = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { projectId: number; memberId: number; targetMemberId: number }>({
// mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
// onSuccess: (_, { projectId }) => {
// queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
// },
// });
// };

View File

@ -1,57 +0,0 @@
// import { useMutation, useQueryClient } from '@tanstack/react-query';
// import { createReview, updateReview, deleteReview, updateReviewStatus } from '@/api/reviewApi';
// import { ReviewRequest, ReviewResponse } from '@/types';
// // 리뷰 생성 훅
// export const useCreateReview = () => {
// const queryClient = useQueryClient();
// return useMutation<ReviewResponse, Error, { projectId: number; memberId: number; reviewData: ReviewRequest }>({
// mutationFn: ({ projectId, memberId, reviewData }) => createReview(projectId, memberId, reviewData),
// onSuccess: (_, { projectId, memberId }) => {
// queryClient.invalidateQueries({ queryKey: ['reviewList', projectId, memberId] });
// },
// });
// };
// // 리뷰 수정 훅
// export const useUpdateReview = () => {
// const queryClient = useQueryClient();
// return useMutation<
// ReviewResponse,
// Error,
// { projectId: number; reviewId: number; memberId: number; reviewData: ReviewRequest }
// >({
// mutationFn: ({ projectId, reviewId, memberId, reviewData }) =>
// updateReview(projectId, reviewId, memberId, reviewData),
// onSuccess: (_, { projectId, reviewId }) => {
// queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
// },
// });
// };
// // 리뷰 삭제 훅
// export const useDeleteReview = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { projectId: number; reviewId: number; memberId: number }>({
// mutationFn: ({ projectId, reviewId, memberId }) => deleteReview(projectId, reviewId, memberId),
// onSuccess: (_, { projectId, reviewId }) => {
// queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
// },
// });
// };
// // 리뷰 상태 변경 훅
// export const useUpdateReviewStatus = () => {
// const queryClient = useQueryClient();
// return useMutation<
// ReviewResponse,
// Error,
// { projectId: number; reviewId: number; memberId: number; reviewStatus: string }
// >({
// mutationFn: ({ projectId, reviewId, memberId, reviewStatus }) =>
// updateReviewStatus(projectId, reviewId, memberId, reviewStatus),
// onSuccess: (_, { projectId, reviewId }) => {
// queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
// },
// });
// };

View File

@ -0,0 +1,59 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import useAuthStore from '@/stores/useAuthStore';
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
import useCreateReviewQuery from '@/queries/reviews/useCreateReviewQuery';
import type { ReviewRequest } from '@/types';
export default function useReviewRequest() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const navigate = useNavigate();
const [selectedImages, setSelectedImages] = useState<number[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0;
const { data: projects } = useProjectListQuery(Number(workspaceId), memberId);
const createReview = useCreateReviewQuery();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ReviewRequest>();
const onSubmit = (data: ReviewRequest) => {
if (!selectedProjectId) {
return;
}
createReview.mutate(
{
projectId: Number(selectedProjectId),
memberId,
reviewData: {
...data,
imageIds: selectedImages,
},
},
{
onSuccess: () => {
navigate(`/admin/${workspaceId}/reviews`);
},
}
);
};
return {
register,
handleSubmit,
errors,
projects,
onSubmit,
selectedProjectId,
setSelectedProjectId,
selectedImages,
setSelectedImages,
};
}

View File

@ -0,0 +1,49 @@
// 임시 가짜 훅
import { useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import useTrainStore from '@/stores/useTrainStore';
export default function useTrainPolling(start: boolean, projectId?: string | null) {
const { addTrainingData, resetTrainingData } = useTrainStore((state) => ({
addTrainingData: state.addTrainingData,
resetTrainingData: state.resetTrainingData,
}));
const intervalIdRef = useRef<number | null>(null);
// 함수 api 후 교체 예정
const fetchTrainingData = useCallback(async () => {
if (projectId) {
try {
const response = await axios.get(`/api/바보=${projectId}`);
const data = response.data;
addTrainingData(projectId, {
epoch: data.epoch,
total_epochs: data.total_epochs,
box_loss: data.box_loss,
cls_loss: data.cls_loss,
dfl_loss: data.dfl_loss,
fitness: data.fitness,
epoch_time: data.epoch_time,
left_second: data.left_second,
});
} catch (error) {
console.error('Fetching error:', error);
}
}
}, [projectId, addTrainingData]);
useEffect(() => {
if (start && projectId) {
resetTrainingData(projectId);
intervalIdRef.current = window.setInterval(fetchTrainingData, 5000);
}
return () => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
};
}, [start, projectId, fetchTrainingData, resetTrainingData]);
}

View File

@ -1,155 +0,0 @@
// TODO: 훅 재설계
// import { useQuery } from '@tanstack/react-query';
// import { getWorkspace, getWorkspaceList } from '@/api/workspaceApi';
// export const useGetWorkspace = (workspaceId: number, memberId: number) => {
// return useQuery({
// queryKey: ['workspace', workspaceId],
// queryFn: () => getWorkspace(workspaceId, memberId),
// });
// };
// export const useGetWorkspaceList = (memberId: number, lastWorkspaceId?: number, limit?: number) => {
// return useQuery({
// queryKey: ['workspaces'],
// queryFn: () => getWorkspaceList(memberId, lastWorkspaceId, limit),
// });
// };
// TODO: 수정된 쿼리에 맞게 훅 수정
// export const useUpdateWorkspace = (): UseMutationResult<
// BaseResponse<WorkspaceResponse>,
// AxiosError<CustomError>,
// { workspaceId: number; memberId: number; data: { title: string; content: string } }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ workspaceId, memberId, data }) => updateWorkspace(workspaceId, memberId, data),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useDeleteWorkspace = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { workspaceId: number; memberId: number }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ workspaceId, memberId }) => deleteWorkspace(workspaceId, memberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useCreateWorkspace = (): UseMutationResult<
// BaseResponse<WorkspaceResponse>,
// AxiosError<CustomError>,
// { memberId: number; data: { title: string; content: string } }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ memberId, data }) => createWorkspace(memberId, data),
// onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ['workspaces'] });
// },
// });
// };
// export const useAddWorkspaceMember = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { workspaceId: number; memberId: number; newMemberId: number }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ workspaceId, memberId, newMemberId }) => addWorkspaceMember(workspaceId, memberId, newMemberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useRemoveWorkspaceMember = (): UseMutationResult<
// BaseResponse<null>,
// AxiosError<CustomError>,
// { workspaceId: number; memberId: number; targetMemberId: number }
// > => {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: ({ workspaceId, memberId, targetMemberId }) =>
// removeWorkspaceMember(workspaceId, memberId, targetMemberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// import { useMutation, useQueryClient } from '@tanstack/react-query';
// import {
// createWorkspace,
// updateWorkspace,
// deleteWorkspace,
// addWorkspaceMember,
// removeWorkspaceMember,
// } from '@/api/workspaceApi';
// import { WorkspaceResponse, WorkspaceRequest } from '@/types';
// export const useCreateWorkspace = () => {
// const queryClient = useQueryClient();
// return useMutation<WorkspaceResponse, Error, { memberId: number; data: WorkspaceRequest }>({
// mutationFn: ({ memberId, data }) => createWorkspace(memberId, data),
// onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ['workspaceList'] });
// },
// });
// };
// export const useUpdateWorkspace = () => {
// const queryClient = useQueryClient();
// return useMutation<WorkspaceResponse, Error, { workspaceId: number; memberId: number; data: WorkspaceRequest }>({
// mutationFn: ({ workspaceId, memberId, data }) => updateWorkspace(workspaceId, memberId, data),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useDeleteWorkspace = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { workspaceId: number; memberId: number }>({
// mutationFn: ({ workspaceId, memberId }) => deleteWorkspace(workspaceId, memberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useAddWorkspaceMember = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { workspaceId: number; memberId: number; newMemberId: number }>({
// mutationFn: ({ workspaceId, memberId, newMemberId }) => addWorkspaceMember(workspaceId, memberId, newMemberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };
// export const useRemoveWorkspaceMember = () => {
// const queryClient = useQueryClient();
// return useMutation<void, Error, { workspaceId: number; memberId: number; targetMemberId: number }>({
// mutationFn: ({ workspaceId, memberId, targetMemberId }) =>
// removeWorkspaceMember(workspaceId, memberId, targetMemberId),
// onSuccess: (_, variables) => {
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
// },
// });
// };

View File

@ -7,7 +7,6 @@ import {
MemberResponse,
RefreshTokenResponse,
AutoLabelingResponse,
ProjectListResponse,
ErrorResponse,
} from '@/types';
@ -100,15 +99,15 @@ export const handlers = [
| 'segmentation',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
thumbnail: `thumbnail_${lastProjectId + index + 1}.jpg`,
}));
// 응답 생성
const response: ProjectListResponse = {
workspaceResponses: projects,
};
const response: ProjectResponse[] = projects;
return HttpResponse.json(response);
}),
http.get('/api/projects/:projectId', ({ params }) => {
// 프로젝트 조회 핸들러
const { projectId } = params;

View File

@ -1,16 +0,0 @@
import WorkspaceDropdownMenu from '@/components/WorkspaceDropdownMenu';
import { useParams } from 'react-router-dom';
export default function ImageFolderUploadTest() {
const params = useParams<{ workspaceId: string; projectId: string }>();
const projectId = Number(params.projectId);
return (
<div className="min-h-screen w-full">
<WorkspaceDropdownMenu
projectId={projectId}
folderId={0}
/>
</div>
);
}

View File

@ -0,0 +1,12 @@
import { Suspense } from 'react';
import ModelManage from '@/components/ModelManage';
export default function ModelDetail() {
return (
<Suspense fallback={<div></div>}>
<div className="flex h-full w-full justify-center">
<ModelManage />
</div>
</Suspense>
);
}

View File

@ -0,0 +1,13 @@
import { Smile } from 'lucide-react';
export default function ModelIndex() {
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<Smile
size={48}
className="mb-2 text-gray-300"
/>
<div className="body-strong text-gray-400"> .</div>
</div>
);
}

View File

@ -1,268 +0,0 @@
import { Rabbit, Bird, Turtle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import ModelLineChart from '@/components/ModelLineChart';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
import ModelBarChart from '@/components/ModelBarChart';
export default function ModelManage() {
interface MetricData {
epoch: string;
loss1: number;
loss2: number;
loss3: number;
fitness: number;
}
const dummyLossData: MetricData[] = [
{ epoch: '1', loss1: 0.45, loss2: 0.43, loss3: 0.42, fitness: 0.97 },
{ epoch: '2', loss1: 0.4, loss2: 0.38, loss3: 0.37, fitness: 0.98 },
{ epoch: '3', loss1: 0.38, loss2: 0.36, loss3: 0.35, fitness: 0.99 },
{ epoch: '4', loss1: 0.36, loss2: 0.34, loss3: 0.33, fitness: 1.0 },
];
const [lossData] = useState<MetricData[]>(dummyLossData);
const [training, setTraining] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const handleTrainingToggle = () => {
setTraining((prev) => !prev);
};
return (
<div className="grid h-screen w-full pl-[56px]">
<div className="flex flex-col">
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
<h1 className="text-xl font-semibold"> </h1>
</header>
<main className="grid flex-1 gap-4 overflow-auto p-4">
<Tabs
defaultValue="train"
className="w-full"
>
<TabsList>
<TabsTrigger value="train"> </TabsTrigger>
<TabsTrigger value="results"> </TabsTrigger>
</TabsList>
{/* 모델 학습 탭 */}
<TabsContent value="train">
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-6">
<SettingsForm />
<Button
variant={training ? 'destructive' : 'outlinePrimary'}
size="lg"
onClick={handleTrainingToggle}
>
{training ? '학습 중단' : '학습 시작'}
</Button>
</div>
<div className="flex flex-col justify-center">
<ModelLineChart data={lossData} />
</div>
</div>
</TabsContent>
{/* 모델 평가 탭 */}
<TabsContent value="results">
{/* 모델 선택 */}
<div className="mb-4">
<Label htmlFor="select-model"> </Label>
<Select onValueChange={setSelectedModel}>
<SelectTrigger id="select-model">
<SelectValue placeholder="모델을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="genesis">Genesis</SelectItem>
<SelectItem value="explorer">Explorer</SelectItem>
<SelectItem value="quantum">Quantum</SelectItem>
</SelectContent>
</Select>
</div>
{/* 선택된 모델에 따른 BarChart 및 Labeling Preview */}
{selectedModel && (
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-6">
<ModelBarChart
data={[
{ name: 'precision', value: 0.734, fill: 'var(--color-precision)' },
{ name: 'recall', value: 0.75, fill: 'var(--color-recall)' },
{ name: 'mAP50', value: 0.995, fill: 'var(--color-map50)' },
{ name: 'mAP50_95', value: 0.97, fill: 'var(--color-map50-95)' },
{ name: 'fitness', value: 0.973, fill: 'var(--color-fitness)' },
]}
/>
</div>
<div className="flex flex-col justify-center">
<LabelingPreview />
</div>
</div>
)}
</TabsContent>
</Tabs>
</main>
</div>
</div>
);
}
function LabelingPreview() {
return (
<div className="flex items-center justify-center rounded-lg border bg-white p-4">
<p> </p>
</div>
);
}
function SettingsForm() {
return (
<form className="grid w-full gap-6">
<fieldset className="grid gap-6 rounded-lg border p-4">
<legend className="-ml-1 px-1 text-sm font-medium"> </legend>
<div className="grid gap-3">
<Label htmlFor="model"> </Label>
<Select>
<SelectTrigger id="model">
<SelectValue placeholder="모델을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="genesis">
<OptionWithIcon
icon={<Rabbit />}
title="Genesis"
description="일반 사용 사례를 위한 빠른 모델"
/>
</SelectItem>
<SelectItem value="explorer">
<OptionWithIcon
icon={<Bird />}
title="Explorer"
description="효율성을 위한 빠른 모델"
/>
</SelectItem>
<SelectItem value="quantum">
<OptionWithIcon
icon={<Turtle />}
title="Quantum"
description="복잡한 계산을 위한 강력한 모델"
/>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<InputWithLabel
label="훈련/검증 비율"
placeholder="0.8"
id="ratio"
/>
<InputWithLabel
label="에포크 수"
placeholder="50"
id="epochs"
/>
<InputWithLabel
label="Batch"
placeholder="-1"
id="batch"
/>
<SelectWithLabel
label="옵티마이저"
id="optimizer"
options={['SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp']}
placeholder="옵티마이저 선택"
/>
<InputWithLabel
label="학습률(LR0)"
placeholder="0.01"
id="lr0"
/>
<InputWithLabel
label="학습률(LRF)"
placeholder="0.01"
id="lrf"
/>
</div>
</fieldset>
</form>
);
}
interface OptionWithIconProps {
icon: JSX.Element;
title: string;
description: string;
}
function OptionWithIcon({ icon, title, description }: OptionWithIconProps) {
return (
<div className="text-muted-foreground flex items-start gap-3">
{icon}
<div className="grid gap-0.5">
<p>
Neural <span className="text-foreground font-medium">{title}</span>
</p>
<p
className="text-xs"
data-description
>
{description}
</p>
</div>
</div>
);
}
interface InputWithLabelProps {
label: string;
id: string;
placeholder: string;
}
function InputWithLabel({ label, id, placeholder }: InputWithLabelProps) {
return (
<div className="grid gap-3">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="number"
placeholder={placeholder}
/>
</div>
);
}
interface SelectWithLabelProps {
label: string;
id: string;
options: string[];
placeholder: string;
}
function SelectWithLabel({ label, id, options, placeholder }: SelectWithLabelProps) {
return (
<div className="grid gap-3">
<Label htmlFor={id}>{label}</Label>
<Select>
<SelectTrigger id={id}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option}
value={option}
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -9,6 +9,8 @@ import useAddProjectMemberQuery from '@/queries/projects/useAddProjectMemberQuer
import { useEffect, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import MemberAddModal from '@/components/MemberAddModal';
import useIsAdminOrManager from '@/hooks/useIsAdminOrManager';
type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER' | 'NONE';
const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER', 'NONE'];
const roleToStr: { [key in Role]: string } = {
@ -25,20 +27,21 @@ export default function ProjectMemberManage() {
const memberId = profile?.id || 0;
const queryClient = useQueryClient();
const previousProjectId = useRef(projectId);
const { data: projectMembers = [] } = useProjectMembersQuery(Number(projectId), memberId);
const { data: workspaceMembers = [] } = useWorkspaceMembersQuery(Number(workspaceId));
const isAdminOrManager = useIsAdminOrManager(Number(projectId));
const updatePrivilege = useUpdateProjectMemberPrivilegeQuery();
const removeMember = useRemoveProjectMemberQuery();
const addProjectMember = useAddProjectMemberQuery();
useEffect(() => {
if (projectId && previousProjectId.current !== projectId) {
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(previousProjectId.current), memberId] }); // 이전 projectId의 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', Number(workspaceId)] }); // workspaceMembers 무효화
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(previousProjectId.current), memberId] });
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', Number(workspaceId)] });
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(projectId), memberId] });
previousProjectId.current = projectId;
@ -85,34 +88,44 @@ export default function ProjectMemberManage() {
};
return (
<div
key={projectId}
className="flex w-full flex-col gap-6 border-b-[0.67px] border-[#dcdcde] bg-[#fbfafd] p-6"
>
<header className="flex w-full items-center gap-4">
<h1 className="flex-1 text-lg font-semibold text-[#333238]"> </h1>
<MemberAddModal projectId={projectId ? Number(projectId) : 0} />
<div className="grid h-screen w-full">
<div className="flex flex-col">
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-4 border-b px-4">
<h1 className="flex-1 text-xl font-semibold"> </h1>
{isAdminOrManager && <MemberAddModal projectId={projectId ? Number(projectId) : 0} />}
</header>
<main className="grid flex-1 gap-4 overflow-auto p-4">
{sortedMembers.length === 0 ? (
<div className="py-4 text-center"> .</div>
) : (
<div className="flex flex-col gap-2">
{sortedMembers.map((member) => (
<div
key={`${member.memberId}-${member.nickname}`}
className="flex items-center gap-4"
className="flex items-center gap-4 rounded-lg border p-4"
>
<span className="flex-1">{member.nickname}</span>
<img
src={member.profileImage}
alt={member.nickname}
className="h-12 w-12 rounded-full"
/>
<span className="flex-1 text-lg font-medium">{member.nickname}</span>
<div className="flex-1">
<Select
onValueChange={(value) => handleRoleChange(member.memberId, value as Role)}
defaultValue={member.privilegeType}
disabled={!isAdminOrManager || member.privilegeType === 'ADMIN'}
>
<SelectTrigger>
<SelectValue placeholder="역할을 선택해주세요." />
<SelectValue placeholder="역할을 선택하세요" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role}
value={role}
disabled={role === 'ADMIN'}
>
{roleToStr[role]}
</SelectItem>
@ -123,5 +136,9 @@ export default function ProjectMemberManage() {
</div>
))}
</div>
)}
</main>
</div>
</div>
);
}

View File

@ -28,7 +28,7 @@ export default function ProjectReviewList() {
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="default"> </Button>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>

View File

@ -1,55 +1,25 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import useCreateReviewQuery from '@/queries/reviews/useCreateReviewQuery';
import type { ReviewRequest } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import useAuthStore from '@/stores/useAuthStore';
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
import ImageSelection from '@/components/ImageSelection';
import useReviewRequest from '@/hooks/useReviewRequest';
import { useNavigate } from 'react-router-dom';
export default function ReviewRequest(): JSX.Element {
const { workspaceId } = useParams<{ workspaceId: string }>();
const navigate = useNavigate();
const [selectedImages, setSelectedImages] = useState<number[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0;
const { data: projectList } = useProjectListQuery(Number(workspaceId), memberId);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ReviewRequest>();
const createReview = useCreateReviewQuery();
const onSubmit = (data: ReviewRequest) => {
if (!selectedProjectId) {
return;
}
createReview.mutate(
{
projectId: Number(selectedProjectId),
memberId,
reviewData: {
...data,
imageIds: selectedImages,
},
},
{
onSuccess: () => {
navigate(`/admin/${workspaceId}/reviews`);
},
}
);
};
errors,
projects,
onSubmit,
selectedProjectId,
setSelectedProjectId,
selectedImages,
setSelectedImages,
} = useReviewRequest();
const navigate = useNavigate();
return (
<div className="review-request-container p-4">
@ -63,8 +33,8 @@ export default function ReviewRequest(): JSX.Element {
<SelectValue placeholder="프로젝트를 선택하세요" />
</SelectTrigger>
<SelectContent>
{projectList?.workspaceResponses.length ? (
projectList.workspaceResponses.map((project) => (
{projects.length ? (
projects.map((project) => (
<SelectItem
key={project.id}
value={project.id.toString()}

View File

@ -16,7 +16,7 @@ export default function WorkspaceBrowseDetail() {
const memberId = profile?.id ?? 0;
const { data: workspaceData } = useWorkspaceQuery(workspaceId, memberId);
const { data: projectsResponse, isError } = useProjectListQuery(workspaceId, memberId);
const { data: projects, isError } = useProjectListQuery(workspaceId, memberId);
const createProject = useCreateProjectQuery();
@ -28,8 +28,6 @@ export default function WorkspaceBrowseDetail() {
});
};
const projects: ProjectResponse[] = projectsResponse?.workspaceResponses ?? [];
return (
<div className="flex h-full w-full flex-col gap-8 px-6 py-4">
<HeaderSection
@ -105,6 +103,7 @@ function ProjectList({ projects, workspaceId }: { projects: ProjectResponse[]; w
title={project.title}
to={`${webPath.workspace()}/${workspaceId}/${project.id}`}
description={project.projectType}
imageUrl={project.thumbnail}
/>
))}
</div>

View File

@ -11,32 +11,39 @@ export default function WorkspaceMemberManage() {
const { data: members = [] } = useWorkspaceMembersQuery(Number(workspaceId));
return (
<div className="flex w-full flex-col gap-6 border-b-[0.67px] border-[#dcdcde] bg-[#fbfafd] p-6">
<header className="flex w-full items-center gap-4">
<h1 className="flex-1 text-lg font-semibold text-[#333238]"> </h1>
<div className="grid h-screen w-full">
<div className="flex flex-col">
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-4 border-b px-4">
<h1 className="flex-1 text-xl font-semibold"> </h1>
<WorkspaceMemberAddModal
workspaceId={Number(workspaceId)}
memberId={memberId}
/>
</header>
<main className="flex-1 overflow-auto p-4">
{members.length === 0 ? (
<div className="py-4 text-center"> .</div>
) : (
members.map((member) => (
<div className="flex flex-wrap gap-4">
{members.map((member) => (
<div
key={member.id}
className="flex items-center gap-4 border-b pb-2"
className="flex w-full items-center gap-4 rounded-lg border p-2 md:w-1/2 lg:w-2/5"
>
<img
src={member.profileImage}
alt={member.nickname}
className="h-8 w-8 rounded-full"
className="h-12 w-12 rounded-full"
/>
<span>{member.nickname}</span>
<span className="flex-1 text-sm font-medium">{member.nickname}</span>
</div>
))}
</div>
))
)}
</main>
</div>
</div>
);
}

View File

@ -28,7 +28,7 @@ export default function WorkspaceReviewList() {
to={`/admin/${workspaceId}/reviews/request`}
className="ml-auto"
>
<Button variant="default"> </Button>
<Button variant="outlinePrimary"> </Button>
</Link>
</header>
<ReviewList

View File

@ -16,17 +16,17 @@ import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex';
import AdminIndex from '@/pages/AdminIndex';
import LabelCanvas from '@/pages/LabelCanvas';
import ReviewDetail from '@/pages/ReviewDetail';
import ImageFolderUploadTest from '@/pages/ImageFolderUploadTest';
import NotFound from '@/pages/NotFound';
import ModelManage from '@/pages/ModelManage';
import ReviewRequest from '@/pages/ReviewRequest';
import ModelIndex from '@/pages/ModelIndex';
import ModelDetail from '@/pages/ModelDetail';
export const webPath = {
home: () => '/',
browse: () => '/browse',
workspace: () => '/workspace',
admin: () => `/admin`,
oauthCallback: () => '/redirect/oauth2',
imageFolderUploadTest: () => '/imagefolderuploadtest',
};
const router = createBrowserRouter([
@ -133,7 +133,11 @@ const router = createBrowserRouter([
children: [
{
index: true,
element: <ModelManage />,
element: <ModelIndex />,
},
{
path: ':projectId',
element: <ModelDetail />,
},
],
},
@ -147,10 +151,6 @@ const router = createBrowserRouter([
</Suspense>
),
},
{
path: `${webPath.imageFolderUploadTest()}/:projectId`,
element: <ImageFolderUploadTest />,
},
]);
export default router;

View File

@ -0,0 +1,40 @@
import { create } from 'zustand';
interface TrainingData {
epoch: number;
total_epochs: number;
box_loss: number;
cls_loss: number;
dfl_loss: number;
fitness: number;
epoch_time: number;
left_second: number;
}
interface StoreState {
trainingDataByProject: { [projectId: string]: TrainingData[] };
addTrainingData: (projectId: string, data: TrainingData) => void;
resetTrainingData: (projectId: string) => void;
}
const useTrainStore = create<StoreState>((set) => ({
trainingDataByProject: {},
addTrainingData: (projectId: string, data: TrainingData) =>
set((state) => ({
trainingDataByProject: {
...state.trainingDataByProject,
[projectId]: [...(state.trainingDataByProject[projectId] || []), data],
},
})),
resetTrainingData: (projectId: string) =>
set((state) => ({
trainingDataByProject: {
...state.trainingDataByProject,
[projectId]: [],
},
})),
}));
export default useTrainStore;

View File

@ -122,18 +122,15 @@ export interface ProjectRequest {
projectType: 'classification' | 'detection' | 'segmentation';
}
export interface ProjectResponse {
export type ProjectResponse = {
id: number;
title: string;
workspaceId: number;
projectType: 'classification' | 'detection' | 'segmentation';
createdAt: string;
updatedAt: string;
}
export interface ProjectListResponse {
workspaceResponses: ProjectResponse[];
}
thumbnail?: string; // Optional
};
// 댓글 관련 DTO
export interface CommentRequest {