feat: implement project management features and UI
This commit is contained in:
parent
1aa50ab1c7
commit
71c191c8bf
43
package-lock.json
generated
43
package-lock.json
generated
@ -11,7 +11,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"axios": "^1.17.0",
|
||||
@ -1531,6 +1531,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||
@ -1742,12 +1760,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz",
|
||||
"integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
"@radix-ui/react-compose-refs": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@ -1759,6 +1777,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
|
||||
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"axios": "^1.17.0",
|
||||
|
||||
@ -11,12 +11,12 @@ export interface ProjectRequest {
|
||||
}
|
||||
|
||||
export interface ProjectResponse {
|
||||
id?: string;
|
||||
name?: string;
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
/** @minLength 1 */
|
||||
name: string;
|
||||
description?: string;
|
||||
workspaceId?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRequest {
|
||||
@ -40,3 +40,10 @@ export type GetWorkspacesParams = {
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type GetProjectsParams = {
|
||||
/**
|
||||
* Filter projects whose name contains this value (case-insensitive)
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
@ -20,7 +20,7 @@ import type {
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { ProjectRequest, ProjectResponse } from "../api.schemas";
|
||||
import type { GetProjectsParams, ProjectRequest, ProjectResponse } from "../api.schemas";
|
||||
|
||||
import { customInstance } from "../../api";
|
||||
|
||||
@ -188,11 +188,12 @@ export const useDeleteProject = <TError = unknown, TContext = unknown>(
|
||||
return useMutation(getDeleteProjectMutationOptions(options), queryClient);
|
||||
};
|
||||
/**
|
||||
* Retrieve all projects belonging to a workspace.
|
||||
* Retrieve all projects belonging to a workspace, optionally filtered by name.
|
||||
* @summary List projects of a workspace
|
||||
*/
|
||||
export const getProjects = (
|
||||
workspaceId: string,
|
||||
params?: GetProjectsParams,
|
||||
options?: SecondParameter<typeof customInstance>,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
@ -200,14 +201,15 @@ export const getProjects = (
|
||||
{
|
||||
url: `/workspaces/${encodeURIComponent(String(workspaceId))}/projects`,
|
||||
method: "GET",
|
||||
params,
|
||||
signal,
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetProjectsQueryKey = (workspaceId: string) => {
|
||||
return [`/workspaces/${workspaceId}/projects`] as const;
|
||||
export const getGetProjectsQueryKey = (workspaceId: string, params?: GetProjectsParams) => {
|
||||
return [`/workspaces/${workspaceId}/projects`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetProjectsQueryOptions = <
|
||||
@ -215,6 +217,7 @@ export const getGetProjectsQueryOptions = <
|
||||
TError = unknown,
|
||||
>(
|
||||
workspaceId: string,
|
||||
params?: GetProjectsParams,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
@ -222,10 +225,10 @@ export const getGetProjectsQueryOptions = <
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetProjectsQueryKey(workspaceId);
|
||||
const queryKey = queryOptions?.queryKey ?? getGetProjectsQueryKey(workspaceId, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getProjects>>> = ({ signal }) =>
|
||||
getProjects(workspaceId, requestOptions, signal);
|
||||
getProjects(workspaceId, params, requestOptions, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@ -242,6 +245,7 @@ export type GetProjectsQueryError = unknown;
|
||||
|
||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||
workspaceId: string,
|
||||
params: undefined | GetProjectsParams,
|
||||
options: {
|
||||
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
||||
Pick<
|
||||
@ -258,6 +262,7 @@ export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>,
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||
workspaceId: string,
|
||||
params?: GetProjectsParams,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
||||
Pick<
|
||||
@ -274,6 +279,7 @@ export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>,
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||
workspaceId: string,
|
||||
params?: GetProjectsParams,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
@ -286,13 +292,14 @@ export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>,
|
||||
|
||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||
workspaceId: string,
|
||||
params?: GetProjectsParams,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
},
|
||||
queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
const queryOptions = getGetProjectsQueryOptions(workspaceId, options);
|
||||
const queryOptions = getGetProjectsQueryOptions(workspaceId, params, options);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
|
||||
@ -26,6 +26,116 @@ import { customInstance } from "../../api";
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
/**
|
||||
* Retrieve a single workspace by its unique identifier.
|
||||
* @summary Get a workspace by ID
|
||||
*/
|
||||
export const getWorkspace = (
|
||||
id: string,
|
||||
options?: SecondParameter<typeof customInstance>,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
return customInstance<WorkspaceResponse>(
|
||||
{ url: `/workspaces/${encodeURIComponent(String(id))}`, method: "GET", signal },
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetWorkspaceQueryKey = (id: string) => {
|
||||
return [`/workspaces/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetWorkspaceQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getWorkspace>>,
|
||||
TError = unknown,
|
||||
>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
}
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetWorkspaceQueryKey(id);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getWorkspace>>> = ({ signal }) =>
|
||||
getWorkspace(id, requestOptions, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: id !== null && id !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetWorkspaceQueryResult = NonNullable<Awaited<ReturnType<typeof getWorkspace>>>;
|
||||
export type GetWorkspaceQueryError = unknown;
|
||||
|
||||
export function useGetWorkspace<TData = Awaited<ReturnType<typeof getWorkspace>>, TError = unknown>(
|
||||
id: string,
|
||||
options: {
|
||||
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData>> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getWorkspace>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getWorkspace>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
},
|
||||
queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
export function useGetWorkspace<TData = Awaited<ReturnType<typeof getWorkspace>>, TError = unknown>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData>> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getWorkspace>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getWorkspace>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
},
|
||||
queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
export function useGetWorkspace<TData = Awaited<ReturnType<typeof getWorkspace>>, TError = unknown>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
},
|
||||
queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
/**
|
||||
* @summary Get a workspace by ID
|
||||
*/
|
||||
|
||||
export function useGetWorkspace<TData = Awaited<ReturnType<typeof getWorkspace>>, TError = unknown>(
|
||||
id: string,
|
||||
options?: {
|
||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getWorkspace>>, TError, TData>>;
|
||||
request?: SecondParameter<typeof customInstance>;
|
||||
},
|
||||
queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
const queryOptions = getGetWorkspaceQueryOptions(id, options);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a workspace
|
||||
* @summary Update a workspace
|
||||
|
||||
50
src/components/Breadcrumbs.tsx
Normal file
50
src/components/Breadcrumbs.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Fragment } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb.tsx";
|
||||
import { Home } from "lucide-react";
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items?: Breadcrumb[];
|
||||
}
|
||||
|
||||
export const Breadcrumbs = ({ items = [] }: BreadcrumbsProps) => {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/">
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
{items.map((item, index, array) => (
|
||||
<Fragment key={`${index}-${item.label}`}>
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={item.href}>{item.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index !== array.length - 1 && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
101
src/components/ui/breadcrumb.tsx
Normal file
101
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
|
||||
)
|
||||
);
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:h-3.5 [&>svg]:w-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@ -43,11 +43,11 @@ export const WorkspaceDialog = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Workspace" : "Create Workspace"}</DialogTitle>
|
||||
<DialogTitle>{isEditing ? "Edit Home" : "Create Home"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Update your workspace details."
|
||||
: "Create a new workspace to organize your reverse engineering projects."}
|
||||
? "Update your home details."
|
||||
: "Create a new home to organize your reverse engineering projects."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
@ -76,7 +76,7 @@ export const WorkspaceDialog = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
{isEditing ? "Save Changes" : "Create Workspace"}
|
||||
{isEditing ? "Save Changes" : "Create Home"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
86
src/features/home/hooks/useHomePage.ts
Normal file
86
src/features/home/hooks/useHomePage.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
useCreateWorkspace,
|
||||
useDeleteWorkspace,
|
||||
useGetWorkspaces,
|
||||
useUpdateWorkspace,
|
||||
} from "@/api/generated/workspace/workspace.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WorkspaceResponse } from "@/api/generated/api.schemas.ts";
|
||||
|
||||
export function useHomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingWorkspace, setEditingWorkspace] = useState<WorkspaceResponse | undefined>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [workspaceToDelete, setWorkspaceToDelete] = useState<WorkspaceResponse | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchQuery, 300);
|
||||
|
||||
const { data: workspaces, queryKey } = useGetWorkspaces(
|
||||
debouncedSearch ? { name: debouncedSearch } : undefined
|
||||
);
|
||||
|
||||
const { mutate: mutateCreateWorkspace } = useCreateWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const { mutate: mutateUpdateWorkspace } = useUpdateWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const { mutate: mutateDeleteWorkspace } = useDeleteWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setEditingWorkspace(undefined);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (workspace: WorkspaceResponse) => {
|
||||
setEditingWorkspace(workspace);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDelete = (workspace: WorkspaceResponse) => {
|
||||
setWorkspaceToDelete(workspace);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = (data: { name: string; description: string }) => {
|
||||
mutateCreateWorkspace({ data });
|
||||
};
|
||||
|
||||
const handleEditWorkspace = (data: { name: string; description: string }) => {
|
||||
if (!editingWorkspace) return;
|
||||
mutateUpdateWorkspace({ id: editingWorkspace.id, data });
|
||||
setEditingWorkspace(undefined);
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = () => {
|
||||
if (!workspaceToDelete) return;
|
||||
mutateDeleteWorkspace({ id: workspaceToDelete.id });
|
||||
setWorkspaceToDelete(null);
|
||||
};
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
debouncedSearch,
|
||||
dialogOpen,
|
||||
editingWorkspace,
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
workspaceToDelete,
|
||||
handleDialogOpenChange,
|
||||
handleOpenEdit,
|
||||
handleOpenDelete,
|
||||
handleCreateWorkspace,
|
||||
handleEditWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
};
|
||||
}
|
||||
77
src/features/project/ProjectCard.tsx
Normal file
77
src/features/project/ProjectCard.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { FileCode, MoreVertical, Pencil, Trash2, Calendar } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ProjectResponse } from "@/api/generated/api.schemas.ts";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
onEdit?: (project: ProjectResponse) => void;
|
||||
onDelete?: (project: ProjectResponse) => void;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({ project, onEdit, onDelete }: ProjectCardProps) => {
|
||||
return (
|
||||
<Card className="group hover:border-primary/50 relative transition-colors">
|
||||
<Link to={`/project/${project.id}`} className="absolute inset-0 z-10" />
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-chart-2/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileCode className="text-chart-2 h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground group-hover:text-primary font-semibold transition-colors">
|
||||
{project.name}
|
||||
</h3>
|
||||
{/*<div className="text-muted-foreground flex items-center gap-1 text-xs">*/}
|
||||
{/* <Binary className="h-3 w-3" />*/}
|
||||
{/* <span>*/}
|
||||
{/* {project.binaryCount} binar{project.binaryCount !== 1 ? "ies" : "y"}*/}
|
||||
{/* </span>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative z-20 h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit?.(project)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(project)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">{project.description}</p>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Updated {formatDistanceToNow(project.updatedAt, { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
83
src/features/project/ProjectDialog.tsx
Normal file
83
src/features/project/ProjectDialog.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProjectResponse } from "@/api/generated/api.schemas.ts";
|
||||
|
||||
interface ProjectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project?: ProjectResponse;
|
||||
onSave: (data: { name: string; description: string }) => void;
|
||||
}
|
||||
|
||||
export const ProjectDialog = ({ open, onOpenChange, project, onSave }: ProjectDialogProps) => {
|
||||
const [name, setName] = useState(project?.name || "");
|
||||
const [description, setDescription] = useState(project?.description || "");
|
||||
|
||||
const isEditing = !!project;
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({ name: name.trim(), description: description.trim() });
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Project" : "Create Project"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Update your project details."
|
||||
: "Create a new project to analyze binary files."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-name">Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Commander Keen Analysis"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-description">Description</Label>
|
||||
<Textarea
|
||||
id="project-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what you are analyzing..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
{isEditing ? "Save Changes" : "Create Project"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
109
src/features/project/hooks/useWorkspacePage.ts
Normal file
109
src/features/project/hooks/useWorkspacePage.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts";
|
||||
import {
|
||||
useCreateProject,
|
||||
useDeleteProject,
|
||||
useGetProjects,
|
||||
useUpdateProject,
|
||||
} from "@/api/generated/project/project.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProjectResponse } from "@/api/generated/api.schemas.ts";
|
||||
|
||||
export function useWorkspacePage(workspaceId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<ProjectResponse | undefined>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<ProjectResponse | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchQuery, 300);
|
||||
|
||||
const { data: workspace, isPending: isPendingWorkspace } = useGetWorkspace(workspaceId, {
|
||||
query: { enabled: !!workspaceId },
|
||||
});
|
||||
|
||||
const { data: projects, queryKey } = useGetProjects(
|
||||
workspaceId,
|
||||
{
|
||||
name: debouncedSearch ? debouncedSearch : undefined,
|
||||
},
|
||||
{ query: { enabled: !!workspaceId } }
|
||||
);
|
||||
|
||||
const { mutate: mutateCreateProject } = useCreateProject({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: mutateUpdateProject } = useUpdateProject({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: mutateDeleteProject } = useDeleteProject({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setEditingProject(undefined);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (project: ProjectResponse) => {
|
||||
setEditingProject(project);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDelete = (project: ProjectResponse) => {
|
||||
setProjectToDelete(project);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateProject = (data: { name: string; description: string }) => {
|
||||
mutateCreateProject({ workspaceId, data });
|
||||
};
|
||||
|
||||
const handleEditProject = (data: { name: string; description: string }) => {
|
||||
if (!editingProject) return;
|
||||
mutateUpdateProject({ workspaceId, projectId: editingProject.id, data });
|
||||
setEditingProject(undefined);
|
||||
};
|
||||
|
||||
const handleDeleteProject = () => {
|
||||
if (!projectToDelete) return;
|
||||
mutateDeleteProject({ workspaceId, projectId: projectToDelete.id });
|
||||
setProjectToDelete(null);
|
||||
};
|
||||
|
||||
return {
|
||||
workspace,
|
||||
isPendingWorkspace,
|
||||
projects,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
debouncedSearch,
|
||||
dialogOpen,
|
||||
editingProject,
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
projectToDelete,
|
||||
handleDialogOpenChange,
|
||||
handleOpenEdit,
|
||||
handleOpenDelete,
|
||||
handleCreateProject,
|
||||
handleEditProject,
|
||||
handleDeleteProject,
|
||||
};
|
||||
}
|
||||
111
src/pages/Home.tsx
Normal file
111
src/pages/Home.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { FolderOpen, Plus, Search } from "lucide-react";
|
||||
import { EmptyState } from "@/components/EmptyState.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { WorkspaceCard } from "@/features/home/WorkspaceCard.tsx";
|
||||
import { WorkspaceDialog } from "@/features/home/WorkspaceDialog.tsx";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog.tsx";
|
||||
import { useHomePage } from "@/features/home/hooks/useHomePage.ts";
|
||||
|
||||
const Home = () => {
|
||||
const {
|
||||
workspaces,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
debouncedSearch,
|
||||
dialogOpen,
|
||||
editingWorkspace,
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
workspaceToDelete,
|
||||
handleDialogOpenChange,
|
||||
handleOpenEdit,
|
||||
handleOpenDelete,
|
||||
handleCreateWorkspace,
|
||||
handleEditWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
} = useHomePage();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-foreground text-2xl font-semibold">Workspaces</h1>
|
||||
<p className="text-muted-foreground mt-1">Organize your reverse engineering projects</p>
|
||||
</div>
|
||||
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6 max-w-1/3">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search workspaces..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*Home Grid */}
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{workspaces.map((workspace) => (
|
||||
<WorkspaceCard
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
onEdit={handleOpenEdit}
|
||||
onDelete={handleOpenDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={debouncedSearch ? "No workspaces found" : "No workspaces yet"}
|
||||
description={
|
||||
debouncedSearch
|
||||
? "Try adjusting your search query"
|
||||
: "Create your first workspace to start analyzing binaries"
|
||||
}
|
||||
action={
|
||||
!debouncedSearch && (
|
||||
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Workspace
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*Create/Edit Dialog*/}
|
||||
<WorkspaceDialog
|
||||
key={editingWorkspace?.id ?? "new"}
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
workspace={editingWorkspace}
|
||||
onSave={editingWorkspace ? handleEditWorkspace : handleCreateWorkspace}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Delete Workspace"
|
||||
description={`Are you sure you want to delete "${workspaceToDelete?.name}"? This will also delete all projects and binaries within it. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteWorkspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@ -3,7 +3,9 @@ import { AppLayout } from "@/components/layout/AppLayout.tsx";
|
||||
import { lazy } from "react";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const Workspace = lazy(() => import("./Workspace.tsx"));
|
||||
const Home = lazy(() => import("./Home"));
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const Workspace = lazy(() => import("./Workspace"));
|
||||
|
||||
export const Router = createBrowserRouter([
|
||||
{
|
||||
@ -15,6 +17,10 @@ export const Router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
index: true,
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/workspace/:workspaceId",
|
||||
element: <Workspace />,
|
||||
},
|
||||
],
|
||||
|
||||
@ -1,83 +1,72 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { FolderOpen, Plus, Search } from "lucide-react";
|
||||
import { useParams } from "react-router";
|
||||
import { EmptyState } from "@/components/EmptyState.tsx";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
useCreateWorkspace,
|
||||
useDeleteWorkspace,
|
||||
useGetWorkspaces,
|
||||
useUpdateWorkspace,
|
||||
} from "@/api/generated/workspace/workspace.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ArrowLeft, FileCode, Plus, Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { WorkspaceCard } from "@/features/workspace/WorkspaceCard.tsx";
|
||||
import { WorkspaceDialog } from "@/features/workspace/WorkspaceDialog.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WorkspaceResponse } from "@/api/generated/api.schemas.ts";
|
||||
import { ProjectCard } from "@/features/project/ProjectCard.tsx";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog.tsx";
|
||||
import { ProjectDialog } from "@/features/project/ProjectDialog.tsx";
|
||||
import { Breadcrumbs } from "@/components/Breadcrumbs.tsx";
|
||||
import { useWorkspacePage } from "@/features/project/hooks/useWorkspacePage.ts";
|
||||
|
||||
const Workspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams();
|
||||
const workspaceId = params.workspaceId;
|
||||
const {
|
||||
workspace,
|
||||
isPendingWorkspace,
|
||||
projects,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
debouncedSearch,
|
||||
dialogOpen,
|
||||
editingProject,
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
projectToDelete,
|
||||
handleDialogOpenChange,
|
||||
handleOpenEdit,
|
||||
handleOpenDelete,
|
||||
handleCreateProject,
|
||||
handleEditProject,
|
||||
handleDeleteProject,
|
||||
} = useWorkspacePage(workspaceId!);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchQuery, 300);
|
||||
if (isPendingWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: workspaces, queryKey } = useGetWorkspaces(
|
||||
debouncedSearch ? { name: debouncedSearch } : undefined
|
||||
);
|
||||
|
||||
const { mutate: mutateCreateWorkspace } = useCreateWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const { mutate: mutateUpdateWorkspace } = useUpdateWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const { mutate: mutateDeleteWorkspace } = useDeleteWorkspace({
|
||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingWorkspace, setEditingWorkspace] = useState<WorkspaceResponse | undefined>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [workspaceToDelete, setWorkspaceToDelete] = useState<WorkspaceResponse | null>(null);
|
||||
|
||||
const handleCreateWorkspace = (data: { name: string; description: string }) => {
|
||||
mutateCreateWorkspace({ data: data });
|
||||
};
|
||||
|
||||
const handleEditWorkspace = (data: { name: string; description: string }) => {
|
||||
if (!editingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutateUpdateWorkspace({ id: editingWorkspace.id, data: data });
|
||||
|
||||
setEditingWorkspace(undefined);
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = () => {
|
||||
if (!workspaceToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutateDeleteWorkspace({ id: workspaceToDelete.id });
|
||||
|
||||
setWorkspaceToDelete(null);
|
||||
};
|
||||
if (!workspace && !isPendingWorkspace) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<EmptyState
|
||||
title="Workspace not found"
|
||||
description="The workspace you are looking for does not exist."
|
||||
action={
|
||||
<Button onClick={() => {}}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workspaces
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={[{ label: "Workspaces", href: "/" }, { label: workspace!.name }]} />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="mt-6 mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-foreground text-2xl font-semibold">Workspaces</h1>
|
||||
<p className="text-muted-foreground mt-1">Organize your reverse engineering projects</p>
|
||||
<h1 className="text-foreground text-2xl font-semibold">{workspace!.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">{workspace!.description}</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Workspace
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -86,7 +75,7 @@ const Workspace = () => {
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search workspaces..."
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@ -94,67 +83,56 @@ const Workspace = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*Workspace Grid */}
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
{/* Project Grid */}
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{workspaces.map((workspace) => (
|
||||
<WorkspaceCard
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
onEdit={(ws) => {
|
||||
setEditingWorkspace(ws);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onDelete={(ws) => {
|
||||
setWorkspaceToDelete(ws);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onEdit={handleOpenEdit}
|
||||
onDelete={handleOpenDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={searchQuery ? "No workspaces found" : "No workspaces yet"}
|
||||
icon={FileCode}
|
||||
title={debouncedSearch ? "No projects found" : "No projects yet"}
|
||||
description={
|
||||
searchQuery
|
||||
debouncedSearch
|
||||
? "Try adjusting your search query"
|
||||
: "Create your first workspace to start analyzing binaries"
|
||||
: "Create your first project to start uploading binaries"
|
||||
}
|
||||
action={
|
||||
!searchQuery && (
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
!debouncedSearch && (
|
||||
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Workspace
|
||||
Create Project
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*Create/Edit Dialog*/}
|
||||
<WorkspaceDialog
|
||||
key={editingWorkspace?.id ?? "new"}
|
||||
{/* Create/Edit Dialog */}
|
||||
<ProjectDialog
|
||||
key={editingProject?.id ?? "new"}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingWorkspace(undefined);
|
||||
}
|
||||
}}
|
||||
workspace={editingWorkspace}
|
||||
onSave={editingWorkspace ? handleEditWorkspace : handleCreateWorkspace}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
project={editingProject}
|
||||
onSave={editingProject ? handleEditProject : handleCreateProject}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Delete Workspace"
|
||||
description={`Are you sure you want to delete "${workspaceToDelete?.name}"? This will also delete all projects and binaries within it. This action cannot be undone.`}
|
||||
title="Delete Project"
|
||||
description={`Are you sure you want to delete "${projectToDelete?.name}"? This will also delete all binaries within it. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteWorkspace}
|
||||
onConfirm={handleDeleteProject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user