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..eb419feff1
--- /dev/null
+++ b/packages/shared/src/features/profile/components/gear/GearModal.tsx
@@ -0,0 +1,129 @@
+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 (
+
+
+
+
+
+ );
+}
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 95c04c94c8..7a78c04b4f 100644
--- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx
+++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx
@@ -21,6 +21,7 @@ import {
useUserWorkspacePhotos,
MAX_WORKSPACE_PHOTOS,
} from '../../hooks/useUserWorkspacePhotos';
+import { useGear } from '../../hooks/useGear';
import {
Typography,
TypographyType,
@@ -31,10 +32,13 @@ import {
ButtonSize,
ButtonVariant,
} from '../../../../components/buttons/Button';
-import { CameraIcon, PlusIcon } from '../../../../components/icons';
+import { CameraIcon, SettingsIcon } from '../../../../components/icons';
import { SortableWorkspacePhotoItem } from './WorkspacePhotoItem';
import { WorkspacePhotoUploadModal } from './WorkspacePhotoUploadModal';
+import { GearModal } from '../gear/GearModal';
+import { GearItem } from '../gear/GearItem';
import type { AddUserWorkspacePhotoInput } from '../../../../graphql/user/userWorkspacePhoto';
+import type { Gear, AddGearInput } from '../../../../graphql/user/gear';
import { useToastNotification } from '../../../../hooks/useToastNotification';
import { usePrompt } from '../../../../hooks/usePrompt';
@@ -47,10 +51,17 @@ export function ProfileUserWorkspacePhotos({
}: ProfileUserWorkspacePhotosProps): ReactElement | null {
const { photos, isOwner, canAddMore, add, remove, reorder } =
useUserWorkspacePhotos(user);
+ const {
+ gearItems,
+ isOwner: isGearOwner,
+ add: addGear,
+ remove: removeGear,
+ } = useGear(user);
const { displayToast } = useToastNotification();
const { showPrompt } = usePrompt();
- const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
+ const [isGearModalOpen, setIsGearModalOpen] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState(null);
const sensors = useSensors(
@@ -88,7 +99,7 @@ export function ProfileUserWorkspacePhotos({
[photos, reorder, displayToast],
);
- const handleAdd = useCallback(
+ const handleAddPhoto = useCallback(
async (input: AddUserWorkspacePhotoInput) => {
try {
await add(input);
@@ -101,7 +112,20 @@ export function ProfileUserWorkspacePhotos({
[add, displayToast],
);
- const handleDelete = useCallback(
+ const handleAddGear = useCallback(
+ async (input: AddGearInput) => {
+ try {
+ await addGear(input);
+ displayToast('Gear added');
+ } catch (error) {
+ displayToast('Failed to add gear');
+ throw error;
+ }
+ },
+ [addGear, displayToast],
+ );
+
+ const handleDeletePhoto = useCallback(
async (photo: { id: string; image: string }) => {
const confirmed = await showPrompt({
title: 'Remove photo?',
@@ -122,16 +146,45 @@ export function ProfileUserWorkspacePhotos({
[remove, displayToast, showPrompt],
);
- const handleOpenModal = useCallback(() => {
+ const handleDeleteGear = useCallback(
+ async (item: Gear) => {
+ const confirmed = await showPrompt({
+ title: 'Remove gear?',
+ description: `Are you sure you want to remove "${item.gear.name}" from your setup?`,
+ okButton: { title: 'Remove', variant: ButtonVariant.Primary },
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await removeGear(item.id);
+ displayToast('Gear removed');
+ } catch (error) {
+ displayToast('Failed to remove gear');
+ }
+ },
+ [removeGear, displayToast, showPrompt],
+ );
+
+ const handleOpenPhotoModal = useCallback(() => {
if (!canAddMore) {
displayToast(`Maximum of ${MAX_WORKSPACE_PHOTOS} photos allowed`);
return;
}
- setIsModalOpen(true);
+ setIsPhotoModalOpen(true);
}, [canAddMore, displayToast]);
- const handleCloseModal = useCallback(() => {
- setIsModalOpen(false);
+ const handleClosePhotoModal = useCallback(() => {
+ setIsPhotoModalOpen(false);
+ }, []);
+
+ const handleOpenGearModal = useCallback(() => {
+ setIsGearModalOpen(true);
+ }, []);
+
+ const handleCloseGearModal = useCallback(() => {
+ setIsGearModalOpen(false);
}, []);
const handlePhotoClick = useCallback((photo: { image: string }) => {
@@ -150,8 +203,10 @@ export function ProfileUserWorkspacePhotos({
});
const hasPhotos = photos.length > 0;
+ const hasGear = gearItems.length > 0;
+ const hasContent = hasPhotos || hasGear;
- if (!hasPhotos && !isOwner) {
+ if (!hasContent && !isOwner) {
return null;
}
@@ -165,42 +220,73 @@ export function ProfileUserWorkspacePhotos({
>
My Setup
- {isOwner && canAddMore && (
- }
- onClick={handleOpenModal}
- >
- Add
-
+ {isOwner && (
+
+ {canAddMore && (
+ }
+ onClick={handleOpenPhotoModal}
+ >
+ Photo
+
+ )}
+ }
+ onClick={handleOpenGearModal}
+ >
+ Gear
+
+
)}
- {hasPhotos ? (
-
- p.id)}
- strategy={rectSortingStrategy}
- >
-
- {photos.map((photo) => (
-
1}
- onDelete={handleDelete}
- onClick={handlePhotoClick}
+ {hasContent ? (
+ <>
+ {/* Photos Section */}
+ {hasPhotos && (
+
+ p.id)}
+ strategy={rectSortingStrategy}
+ >
+
+ {photos.map((photo) => (
+ 1}
+ onDelete={handleDeletePhoto}
+ onClick={handlePhotoClick}
+ />
+ ))}
+
+
+
+ )}
+
+ {/* Gear Section */}
+ {hasGear && (
+
+ {gearItems.map((item) => (
+
))}
-
-
+ )}
+ >
) : (
isOwner && (
@@ -219,26 +305,44 @@ export function ProfileUserWorkspacePhotos({
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
- Share photos of your desk, setup, or coding environment
+ Share photos of your desk and the gear you use
- }
- onClick={handleOpenModal}
- >
- Add your first photo
-
+
+ }
+ onClick={handleOpenPhotoModal}
+ >
+ Add photo
+
+ }
+ onClick={handleOpenGearModal}
+ >
+ Add gear
+
+
)
)}
- {isModalOpen && (
+ {isPhotoModalOpen && (
+ )}
+
+ {isGearModalOpen && (
+
)}
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..8d78502cc7
--- /dev/null
+++ b/packages/shared/src/features/profile/hooks/useGear.ts
@@ -0,0 +1,67 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo, useCallback } from 'react';
+import type { PublicProfile } from '../../../lib/user';
+import type {
+ 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];