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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@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",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"axios": "^1.17.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": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.16",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz",
|
||||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
"integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@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",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
|
|||||||
@ -11,12 +11,12 @@ export interface ProjectRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectResponse {
|
export interface ProjectResponse {
|
||||||
id?: string;
|
id: string;
|
||||||
name?: string;
|
workspaceId: string;
|
||||||
|
/** @minLength 1 */
|
||||||
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
workspaceId?: string;
|
updatedAt: string;
|
||||||
updatedAt?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRequest {
|
export interface WorkspaceRequest {
|
||||||
@ -40,3 +40,10 @@ export type GetWorkspacesParams = {
|
|||||||
*/
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetProjectsParams = {
|
||||||
|
/**
|
||||||
|
* Filter projects whose name contains this value (case-insensitive)
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import type {
|
|||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { ProjectRequest, ProjectResponse } from "../api.schemas";
|
import type { GetProjectsParams, ProjectRequest, ProjectResponse } from "../api.schemas";
|
||||||
|
|
||||||
import { customInstance } from "../../api";
|
import { customInstance } from "../../api";
|
||||||
|
|
||||||
@ -188,11 +188,12 @@ export const useDeleteProject = <TError = unknown, TContext = unknown>(
|
|||||||
return useMutation(getDeleteProjectMutationOptions(options), queryClient);
|
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
|
* @summary List projects of a workspace
|
||||||
*/
|
*/
|
||||||
export const getProjects = (
|
export const getProjects = (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params?: GetProjectsParams,
|
||||||
options?: SecondParameter<typeof customInstance>,
|
options?: SecondParameter<typeof customInstance>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
@ -200,14 +201,15 @@ export const getProjects = (
|
|||||||
{
|
{
|
||||||
url: `/workspaces/${encodeURIComponent(String(workspaceId))}/projects`,
|
url: `/workspaces/${encodeURIComponent(String(workspaceId))}/projects`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
params,
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetProjectsQueryKey = (workspaceId: string) => {
|
export const getGetProjectsQueryKey = (workspaceId: string, params?: GetProjectsParams) => {
|
||||||
return [`/workspaces/${workspaceId}/projects`] as const;
|
return [`/workspaces/${workspaceId}/projects`, ...(params ? [params] : [])] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGetProjectsQueryOptions = <
|
export const getGetProjectsQueryOptions = <
|
||||||
@ -215,6 +217,7 @@ export const getGetProjectsQueryOptions = <
|
|||||||
TError = unknown,
|
TError = unknown,
|
||||||
>(
|
>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params?: GetProjectsParams,
|
||||||
options?: {
|
options?: {
|
||||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||||
request?: SecondParameter<typeof customInstance>;
|
request?: SecondParameter<typeof customInstance>;
|
||||||
@ -222,10 +225,10 @@ export const getGetProjectsQueryOptions = <
|
|||||||
) => {
|
) => {
|
||||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
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 }) =>
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getProjects>>> = ({ signal }) =>
|
||||||
getProjects(workspaceId, requestOptions, signal);
|
getProjects(workspaceId, params, requestOptions, signal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey,
|
queryKey,
|
||||||
@ -242,6 +245,7 @@ export type GetProjectsQueryError = unknown;
|
|||||||
|
|
||||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params: undefined | GetProjectsParams,
|
||||||
options: {
|
options: {
|
||||||
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
||||||
Pick<
|
Pick<
|
||||||
@ -258,6 +262,7 @@ export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>,
|
|||||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params?: GetProjectsParams,
|
||||||
options?: {
|
options?: {
|
||||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>> &
|
||||||
Pick<
|
Pick<
|
||||||
@ -274,6 +279,7 @@ export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>,
|
|||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params?: GetProjectsParams,
|
||||||
options?: {
|
options?: {
|
||||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||||
request?: SecondParameter<typeof customInstance>;
|
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>(
|
export function useGetProjects<TData = Awaited<ReturnType<typeof getProjects>>, TError = unknown>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
params?: GetProjectsParams,
|
||||||
options?: {
|
options?: {
|
||||||
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getProjects>>, TError, TData>>;
|
||||||
request?: SecondParameter<typeof customInstance>;
|
request?: SecondParameter<typeof customInstance>;
|
||||||
},
|
},
|
||||||
queryClient?: QueryClient
|
queryClient?: QueryClient
|
||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
): 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> & {
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
|
||||||
queryKey: DataTag<QueryKey, 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];
|
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
|
* Update a workspace
|
||||||
* @summary 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}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEditing ? "Edit Workspace" : "Create Workspace"}</DialogTitle>
|
<DialogTitle>{isEditing ? "Edit Home" : "Create Home"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEditing
|
{isEditing
|
||||||
? "Update your workspace details."
|
? "Update your home details."
|
||||||
: "Create a new workspace to organize your reverse engineering projects."}
|
: "Create a new home to organize your reverse engineering projects."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
@ -76,7 +76,7 @@ export const WorkspaceDialog = ({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||||
{isEditing ? "Save Changes" : "Create Workspace"}
|
{isEditing ? "Save Changes" : "Create Home"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</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";
|
import { lazy } from "react";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// 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([
|
export const Router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -15,6 +17,10 @@ export const Router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
|
element: <Home />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/workspace/:workspaceId",
|
||||||
element: <Workspace />,
|
element: <Workspace />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,83 +1,72 @@
|
|||||||
import { Button } from "@/components/ui/button.tsx";
|
import { useParams } from "react-router";
|
||||||
import { FolderOpen, Plus, Search } from "lucide-react";
|
|
||||||
import { EmptyState } from "@/components/EmptyState.tsx";
|
import { EmptyState } from "@/components/EmptyState.tsx";
|
||||||
import { useState } from "react";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { useDebounce } from "use-debounce";
|
import { ArrowLeft, FileCode, Plus, Search } from "lucide-react";
|
||||||
import {
|
|
||||||
useCreateWorkspace,
|
|
||||||
useDeleteWorkspace,
|
|
||||||
useGetWorkspaces,
|
|
||||||
useUpdateWorkspace,
|
|
||||||
} from "@/api/generated/workspace/workspace.ts";
|
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { WorkspaceCard } from "@/features/workspace/WorkspaceCard.tsx";
|
import { ProjectCard } from "@/features/project/ProjectCard.tsx";
|
||||||
import { WorkspaceDialog } from "@/features/workspace/WorkspaceDialog.tsx";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { WorkspaceResponse } from "@/api/generated/api.schemas.ts";
|
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog.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 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("");
|
if (isPendingWorkspace) {
|
||||||
const [debouncedSearch] = useDebounce(searchQuery, 300);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: workspaces, queryKey } = useGetWorkspaces(
|
if (!workspace && !isPendingWorkspace) {
|
||||||
debouncedSearch ? { name: debouncedSearch } : undefined
|
return (
|
||||||
);
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
<EmptyState
|
||||||
const { mutate: mutateCreateWorkspace } = useCreateWorkspace({
|
title="Workspace not found"
|
||||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
description="The workspace you are looking for does not exist."
|
||||||
});
|
action={
|
||||||
|
<Button onClick={() => {}}>
|
||||||
const { mutate: mutateUpdateWorkspace } = useUpdateWorkspace({
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
Back to Workspaces
|
||||||
});
|
</Button>
|
||||||
|
}
|
||||||
const { mutate: mutateDeleteWorkspace } = useDeleteWorkspace({
|
/>
|
||||||
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey }) },
|
</div>
|
||||||
});
|
);
|
||||||
|
}
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-6 py-8">
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs items={[{ label: "Workspaces", href: "/" }, { label: workspace!.name }]} />
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mt-6 mb-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-foreground text-2xl font-semibold">Workspaces</h1>
|
<h1 className="text-foreground text-2xl font-semibold">{workspace!.name}</h1>
|
||||||
<p className="text-muted-foreground mt-1">Organize your reverse engineering projects</p>
|
<p className="text-muted-foreground mt-1">{workspace!.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Workspace
|
New Project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -86,7 +75,7 @@ const Workspace = () => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search workspaces..."
|
placeholder="Search projects..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
@ -94,67 +83,56 @@ const Workspace = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*Workspace Grid */}
|
{/* Project Grid */}
|
||||||
{workspaces && workspaces.length > 0 ? (
|
{projects && projects.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{workspaces.map((workspace) => (
|
{projects.map((project) => (
|
||||||
<WorkspaceCard
|
<ProjectCard
|
||||||
key={workspace.id}
|
key={project.id}
|
||||||
workspace={workspace}
|
project={project}
|
||||||
onEdit={(ws) => {
|
onEdit={handleOpenEdit}
|
||||||
setEditingWorkspace(ws);
|
onDelete={handleOpenDelete}
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onDelete={(ws) => {
|
|
||||||
setWorkspaceToDelete(ws);
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FolderOpen}
|
icon={FileCode}
|
||||||
title={searchQuery ? "No workspaces found" : "No workspaces yet"}
|
title={debouncedSearch ? "No projects found" : "No projects yet"}
|
||||||
description={
|
description={
|
||||||
searchQuery
|
debouncedSearch
|
||||||
? "Try adjusting your search query"
|
? "Try adjusting your search query"
|
||||||
: "Create your first workspace to start analyzing binaries"
|
: "Create your first project to start uploading binaries"
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
!searchQuery && (
|
!debouncedSearch && (
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => handleDialogOpenChange(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Workspace
|
Create Project
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*Create/Edit Dialog*/}
|
{/* Create/Edit Dialog */}
|
||||||
<WorkspaceDialog
|
<ProjectDialog
|
||||||
key={editingWorkspace?.id ?? "new"}
|
key={editingProject?.id ?? "new"}
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={handleDialogOpenChange}
|
||||||
setDialogOpen(open);
|
project={editingProject}
|
||||||
if (!open) {
|
onSave={editingProject ? handleEditProject : handleCreateProject}
|
||||||
setEditingWorkspace(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
workspace={editingWorkspace}
|
|
||||||
onSave={editingWorkspace ? handleEditWorkspace : handleCreateWorkspace}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
title="Delete Workspace"
|
title="Delete Project"
|
||||||
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.`}
|
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"
|
confirmLabel="Delete"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={handleDeleteWorkspace}
|
onConfirm={handleDeleteProject}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user