feat: implement project management features and UI

This commit is contained in:
Rodrigo Verdiani 2026-06-06 21:18:46 -03:00
parent 1aa50ab1c7
commit 71c191c8bf
16 changed files with 883 additions and 125 deletions

43
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
};

View File

@ -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>;

View File

@ -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

View 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>
);
};

View 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,
};

View File

@ -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>

View 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,
};
}

View 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>
);
};

View 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>
);
};

View 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
View 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;

View File

@ -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 />,
},
],

View File

@ -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
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>
);
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);
};
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,38 +83,32 @@ 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>
)
}
@ -133,28 +116,23 @@ const Workspace = () => {
)}
{/* Create/Edit Dialog */}
<WorkspaceDialog
key={editingWorkspace?.id ?? "new"}
<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>
);