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 && ( +
+
+ )} +
+ ); +} + +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} + > +
+ + + Add Gear + + + + {/* Name with autocomplete */} +
+ { + setValue('name', e.target.value); + setShowSuggestions(true); + }} + onFocus={() => { + setShowSuggestions(true); + }} + /> + {filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((suggestion) => ( + + ))} +
+ )} +
+ + {!isMobile && ( + + )} +
+
+
+
+ ); +} 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 && ( + + )} +
+ + {hasItems ? ( + + g.id)} + strategy={verticalListSortingStrategy} + > +
+ {gearItems.map((item) => ( + 1} + onDelete={handleDelete} + /> + ))} +
+
+
+ ) : ( + isOwner && ( +
+
+ +
+ + Share the gear you use with the community + + +
+ ) + )} + + {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];