diff --git a/packages/shared/src/features/profile/components/gear/GearItem.tsx b/packages/shared/src/features/profile/components/gear/GearItem.tsx
new file mode 100644
index 0000000000..b390d028ac
--- /dev/null
+++ b/packages/shared/src/features/profile/components/gear/GearItem.tsx
@@ -0,0 +1,102 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import type { Gear } from '../../../../graphql/user/gear';
+import {
+ Typography,
+ TypographyType,
+ TypographyColor,
+} from '../../../../components/typography/Typography';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../../components/buttons/Button';
+import { TrashIcon } from '../../../../components/icons';
+
+interface GearItemProps {
+ item: Gear;
+ isOwner: boolean;
+ onDelete?: (item: Gear) => void;
+}
+
+export function GearItem({
+ item,
+ isOwner,
+ onDelete,
+}: GearItemProps): ReactElement {
+ const { gear } = item;
+
+ return (
+
+
+
+ {gear.name}
+
+
+ {isOwner && onDelete && (
+
+ }
+ onClick={() => onDelete(item)}
+ aria-label="Delete gear"
+ />
+
+ )}
+
+ );
+}
+
+interface SortableGearItemProps extends GearItemProps {
+ isDraggable?: boolean;
+}
+
+export function SortableGearItem({
+ item,
+ isDraggable = true,
+ ...props
+}: SortableGearItemProps): ReactElement {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: item.id, disabled: !isDraggable });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/shared/src/features/profile/components/gear/GearModal.tsx b/packages/shared/src/features/profile/components/gear/GearModal.tsx
new file mode 100644
index 0000000000..b45d70eebe
--- /dev/null
+++ b/packages/shared/src/features/profile/components/gear/GearModal.tsx
@@ -0,0 +1,152 @@
+import type { ReactElement } from 'react';
+import React, { useMemo, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+import type { ModalProps } from '../../../../components/modals/common/Modal';
+import { Modal } from '../../../../components/modals/common/Modal';
+import { TextField } from '../../../../components/fields/TextField';
+import { Button, ButtonVariant } from '../../../../components/buttons/Button';
+import { ModalHeader } from '../../../../components/modals/common/ModalHeader';
+import { useViewSize, ViewSize } from '../../../../hooks';
+import type { AddGearInput, DatasetGear } from '../../../../graphql/user/gear';
+import { useGearSearch } from '../../hooks/useGearSearch';
+
+const gearFormSchema = z.object({
+ name: z.string().min(1, 'Name is required').max(255),
+});
+
+type GearFormData = z.infer;
+
+type GearModalProps = Omit & {
+ onSubmit: (input: AddGearInput) => Promise;
+};
+
+export function GearModal({
+ onSubmit,
+ ...rest
+}: GearModalProps): ReactElement {
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const isMobile = useViewSize(ViewSize.MobileL);
+
+ const methods = useForm({
+ resolver: zodResolver(gearFormSchema),
+ defaultValues: {
+ name: '',
+ },
+ });
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors, isSubmitting },
+ } = methods;
+
+ const name = watch('name');
+
+ const { results: suggestions } = useGearSearch(name);
+
+ const canSubmit = name.trim().length > 0;
+
+ const handleSelectSuggestion = (suggestion: DatasetGear) => {
+ setValue('name', suggestion.name);
+ setShowSuggestions(false);
+ };
+
+ const onFormSubmit = handleSubmit(async (data) => {
+ await onSubmit({
+ name: data.name.trim(),
+ });
+ rest.onRequestClose?.(null);
+ });
+
+ const filteredSuggestions = useMemo(() => {
+ if (!showSuggestions || name.length < 1) {
+ return [];
+ }
+ return suggestions;
+ }, [suggestions, showSuggestions, name]);
+
+ return (
+
+
+
+ Add Gear
+
+
+ ),
+ rightButtonProps: {
+ variant: ButtonVariant.Primary,
+ disabled: !canSubmit || isSubmitting,
+ loading: isSubmitting,
+ },
+ copy: { right: 'Add' },
+ }}
+ kind={Modal.Kind.FlexibleCenter}
+ size={Modal.Size.Small}
+ {...rest}
+ >
+
+
+
+ );
+}
diff --git a/packages/shared/src/features/profile/components/gear/ProfileUserGear.tsx b/packages/shared/src/features/profile/components/gear/ProfileUserGear.tsx
new file mode 100644
index 0000000000..095747da42
--- /dev/null
+++ b/packages/shared/src/features/profile/components/gear/ProfileUserGear.tsx
@@ -0,0 +1,211 @@
+import type { ReactElement } from 'react';
+import React, { useState, useCallback } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import type { DragEndEvent } from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import type { PublicProfile } from '../../../../lib/user';
+import { useGear } from '../../hooks/useGear';
+import {
+ Typography,
+ TypographyType,
+ TypographyColor,
+} from '../../../../components/typography/Typography';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../../components/buttons/Button';
+import { PlusIcon, SettingsIcon } from '../../../../components/icons';
+import { SortableGearItem } from './GearItem';
+import { GearModal } from './GearModal';
+import type { Gear, AddGearInput } from '../../../../graphql/user/gear';
+import { useToastNotification } from '../../../../hooks/useToastNotification';
+import { usePrompt } from '../../../../hooks/usePrompt';
+
+interface ProfileUserGearProps {
+ user: PublicProfile;
+}
+
+export function ProfileUserGear({
+ user,
+}: ProfileUserGearProps): ReactElement | null {
+ const { gearItems, isOwner, add, remove, reorder } = useGear(user);
+ const { displayToast } = useToastNotification();
+ const { showPrompt } = usePrompt();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8, // Require 8px movement before activating drag
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) {
+ return;
+ }
+
+ const oldIndex = gearItems.findIndex((g) => g.id === active.id);
+ const newIndex = gearItems.findIndex((g) => g.id === over.id);
+ const reordered = arrayMove(gearItems, oldIndex, newIndex);
+
+ reorder(
+ reordered.map((item, index) => ({
+ id: item.id,
+ position: index,
+ })),
+ ).catch(() => {
+ displayToast('Failed to reorder gear');
+ });
+ },
+ [gearItems, reorder, displayToast],
+ );
+
+ const handleAdd = useCallback(
+ async (input: AddGearInput) => {
+ try {
+ await add(input);
+ displayToast('Gear added');
+ } catch (error) {
+ displayToast('Failed to add gear');
+ throw error;
+ }
+ },
+ [add, displayToast],
+ );
+
+ const handleDelete = useCallback(
+ async (item: Gear) => {
+ const confirmed = await showPrompt({
+ title: 'Remove gear?',
+ description: `Are you sure you want to remove "${item.gear.name}" from your gear?`,
+ okButton: { title: 'Remove', variant: ButtonVariant.Primary },
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await remove(item.id);
+ displayToast('Gear removed');
+ } catch (error) {
+ displayToast('Failed to remove gear');
+ }
+ },
+ [remove, displayToast, showPrompt],
+ );
+
+ const handleOpenModal = useCallback(() => {
+ setIsModalOpen(true);
+ }, []);
+
+ const handleCloseModal = useCallback(() => {
+ setIsModalOpen(false);
+ }, []);
+
+ const hasItems = gearItems.length > 0;
+
+ if (!hasItems && !isOwner) {
+ return null;
+ }
+
+ return (
+
+
+
+ Gear
+
+ {isOwner && (
+ }
+ onClick={handleOpenModal}
+ >
+ Add
+
+ )}
+
+
+ {hasItems ? (
+
+ g.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {gearItems.map((item) => (
+ 1}
+ onDelete={handleDelete}
+ />
+ ))}
+
+
+
+ ) : (
+ isOwner && (
+
+
+
+
+
+ Share the gear you use with the community
+
+
}
+ onClick={handleOpenModal}
+ >
+ Add your first gear
+
+
+ )
+ )}
+
+ {isModalOpen && (
+
+ )}
+
+ );
+}
diff --git a/packages/shared/src/features/profile/components/gear/index.ts b/packages/shared/src/features/profile/components/gear/index.ts
new file mode 100644
index 0000000000..a2b1980f97
--- /dev/null
+++ b/packages/shared/src/features/profile/components/gear/index.ts
@@ -0,0 +1,3 @@
+export { GearItem, SortableGearItem } from './GearItem';
+export { GearModal } from './GearModal';
+export { ProfileUserGear } from './ProfileUserGear';
diff --git a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx
index 3de913c035..e6135a0506 100644
--- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx
+++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React, { useState, useCallback } from 'react';
+import { useEventListener } from '../../../../hooks/useEventListener';
import {
DndContext,
closestCenter,
@@ -141,6 +142,13 @@ export function ProfileUserWorkspacePhotos({
setSelectedPhoto(null);
}, []);
+ // Close lightbox on ESC key
+ useEventListener(globalThis, 'keydown', (event) => {
+ if (event.key === 'Escape' && selectedPhoto) {
+ handleCloseLightbox();
+ }
+ });
+
const hasPhotos = photos.length > 0;
if (!hasPhotos && !isOwner) {
diff --git a/packages/shared/src/features/profile/hooks/useGear.ts b/packages/shared/src/features/profile/hooks/useGear.ts
new file mode 100644
index 0000000000..9a9f42ea7e
--- /dev/null
+++ b/packages/shared/src/features/profile/hooks/useGear.ts
@@ -0,0 +1,68 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo, useCallback } from 'react';
+import type { PublicProfile } from '../../../lib/user';
+import type {
+ Gear,
+ AddGearInput,
+ ReorderGearInput,
+} from '../../../graphql/user/gear';
+import {
+ getGear,
+ addGear,
+ deleteGear,
+ reorderGear,
+} from '../../../graphql/user/gear';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import { useAuthContext } from '../../../contexts/AuthContext';
+
+export function useGear(user: PublicProfile | null) {
+ const queryClient = useQueryClient();
+ const { user: loggedUser } = useAuthContext();
+ const isOwner = loggedUser?.id === user?.id;
+
+ const queryKey = generateQueryKey(RequestKey.Gear, user, 'profile');
+
+ const query = useQuery({
+ queryKey,
+ queryFn: () => getGear(user?.id as string),
+ staleTime: StaleTime.Default,
+ enabled: !!user?.id,
+ });
+
+ const gearItems = useMemo(
+ () => query.data?.edges?.map(({ node }) => node) ?? [],
+ [query.data],
+ );
+
+ const invalidateQuery = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey });
+ }, [queryClient, queryKey]);
+
+ const addMutation = useMutation({
+ mutationFn: (input: AddGearInput) => addGear(input),
+ onSuccess: invalidateQuery,
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => deleteGear(id),
+ onSuccess: invalidateQuery,
+ });
+
+ const reorderMutation = useMutation({
+ mutationFn: (items: ReorderGearInput[]) => reorderGear(items),
+ onSuccess: invalidateQuery,
+ });
+
+ return {
+ ...query,
+ gearItems,
+ isOwner,
+ queryKey,
+ add: addMutation.mutateAsync,
+ remove: deleteMutation.mutateAsync,
+ reorder: reorderMutation.mutateAsync,
+ isAdding: addMutation.isPending,
+ isDeleting: deleteMutation.isPending,
+ isReordering: reorderMutation.isPending,
+ };
+}
diff --git a/packages/shared/src/features/profile/hooks/useGearSearch.ts b/packages/shared/src/features/profile/hooks/useGearSearch.ts
new file mode 100644
index 0000000000..1c44af8c2d
--- /dev/null
+++ b/packages/shared/src/features/profile/hooks/useGearSearch.ts
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+import type { DatasetGear } from '../../../graphql/user/gear';
+import { searchGear } from '../../../graphql/user/gear';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+
+export function useGearSearch(query: string) {
+ const trimmedQuery = query.trim();
+ const enabled = trimmedQuery.length >= 1;
+
+ const queryKey = generateQueryKey(RequestKey.GearSearch, null, trimmedQuery);
+
+ const searchQuery = useQuery({
+ queryKey,
+ queryFn: () => searchGear(trimmedQuery),
+ staleTime: StaleTime.Default,
+ enabled,
+ });
+
+ return {
+ ...searchQuery,
+ results: searchQuery.data ?? [],
+ isSearching: searchQuery.isFetching,
+ };
+}
diff --git a/packages/shared/src/graphql/user/gear.ts b/packages/shared/src/graphql/user/gear.ts
new file mode 100644
index 0000000000..c6866660df
--- /dev/null
+++ b/packages/shared/src/graphql/user/gear.ts
@@ -0,0 +1,123 @@
+import { gql } from 'graphql-request';
+import type { Connection } from '../common';
+import { gqlClient } from '../common';
+
+export interface DatasetGear {
+ id: string;
+ name: string;
+}
+
+export interface Gear {
+ id: string;
+ gear: DatasetGear;
+ position: number;
+}
+
+export interface AddGearInput {
+ name: string;
+}
+
+export interface ReorderGearInput {
+ id: string;
+ position: number;
+}
+
+const GEAR_FRAGMENT = gql`
+ fragment GearFragment on Gear {
+ id
+ position
+ gear {
+ id
+ name
+ }
+ }
+`;
+
+const GEAR_QUERY = gql`
+ query Gear($userId: ID!, $first: Int, $after: String) {
+ gear(userId: $userId, first: $first, after: $after) {
+ edges {
+ node {
+ ...GearFragment
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ ${GEAR_FRAGMENT}
+`;
+
+const AUTOCOMPLETE_GEAR_QUERY = gql`
+ query AutocompleteGear($query: String!) {
+ autocompleteGear(query: $query) {
+ id
+ name
+ }
+ }
+`;
+
+const ADD_GEAR_MUTATION = gql`
+ mutation AddGear($input: AddGearInput!) {
+ addGear(input: $input) {
+ ...GearFragment
+ }
+ }
+ ${GEAR_FRAGMENT}
+`;
+
+const DELETE_GEAR_MUTATION = gql`
+ mutation DeleteGear($id: ID!) {
+ deleteGear(id: $id) {
+ _
+ }
+ }
+`;
+
+const REORDER_GEAR_MUTATION = gql`
+ mutation ReorderGear($items: [ReorderGearInput!]!) {
+ reorderGear(items: $items) {
+ ...GearFragment
+ }
+ }
+ ${GEAR_FRAGMENT}
+`;
+
+export const getGear = async (
+ userId: string,
+ first = 50,
+): Promise> => {
+ const result = await gqlClient.request<{
+ gear: Connection;
+ }>(GEAR_QUERY, { userId, first });
+ return result.gear;
+};
+
+export const searchGear = async (query: string): Promise => {
+ const result = await gqlClient.request<{
+ autocompleteGear: DatasetGear[];
+ }>(AUTOCOMPLETE_GEAR_QUERY, { query });
+ return result.autocompleteGear;
+};
+
+export const addGear = async (input: AddGearInput): Promise => {
+ const result = await gqlClient.request<{
+ addGear: Gear;
+ }>(ADD_GEAR_MUTATION, { input });
+ return result.addGear;
+};
+
+export const deleteGear = async (id: string): Promise => {
+ await gqlClient.request(DELETE_GEAR_MUTATION, { id });
+};
+
+export const reorderGear = async (
+ items: ReorderGearInput[],
+): Promise => {
+ const result = await gqlClient.request<{
+ reorderGear: Gear[];
+ }>(REORDER_GEAR_MUTATION, { items });
+ return result.reorderGear;
+};
diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts
index e174311f04..d2a9e630cb 100644
--- a/packages/shared/src/lib/query.ts
+++ b/packages/shared/src/lib/query.ts
@@ -236,6 +236,8 @@ export enum RequestKey {
UserTools = 'user_tools',
ToolSearch = 'tool_search',
UserWorkspacePhotos = 'user_workspace_photos',
+ Gear = 'gear',
+ GearSearch = 'gear_search',
}
export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id];