From 633e5959c945d8f7afe4c9ad1ed935315d268bba Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 21 Jan 2026 16:36:53 +0200 Subject: [PATCH 1/7] feat(profile): add Tools feature for user profiles Add frontend components for users to manage their tools on their profile: - GraphQL queries and mutations for tools - useUserTools and useToolSearch hooks - ProfileUserTools, UserToolItem, UserToolSection, UserToolModal components - Auto-detected favicons from URLs using Google's favicon service Co-Authored-By: Claude Opus 4.5 --- .../components/tools/ProfileUserTools.tsx | 205 ++++++++++++++ .../profile/components/tools/UserToolItem.tsx | 89 ++++++ .../components/tools/UserToolModal.tsx | 267 ++++++++++++++++++ .../components/tools/UserToolSection.tsx | 56 ++++ .../features/profile/hooks/useToolSearch.ts | 24 ++ .../features/profile/hooks/useUserTools.ts | 91 ++++++ packages/shared/src/graphql/user/userTool.ts | 160 +++++++++++ packages/shared/src/lib/query.ts | 2 + packages/webapp/pages/[userId]/index.tsx | 2 + 9 files changed, 896 insertions(+) create mode 100644 packages/shared/src/features/profile/components/tools/ProfileUserTools.tsx create mode 100644 packages/shared/src/features/profile/components/tools/UserToolItem.tsx create mode 100644 packages/shared/src/features/profile/components/tools/UserToolModal.tsx create mode 100644 packages/shared/src/features/profile/components/tools/UserToolSection.tsx create mode 100644 packages/shared/src/features/profile/hooks/useToolSearch.ts create mode 100644 packages/shared/src/features/profile/hooks/useUserTools.ts create mode 100644 packages/shared/src/graphql/user/userTool.ts diff --git a/packages/shared/src/features/profile/components/tools/ProfileUserTools.tsx b/packages/shared/src/features/profile/components/tools/ProfileUserTools.tsx new file mode 100644 index 0000000000..4284f85f75 --- /dev/null +++ b/packages/shared/src/features/profile/components/tools/ProfileUserTools.tsx @@ -0,0 +1,205 @@ +import type { ReactElement } from 'react'; +import React, { useState, useCallback } from 'react'; +import type { PublicProfile } from '../../../../lib/user'; +import { useUserTools } from '../../hooks/useUserTools'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { PlusIcon, HammerIcon } from '../../../../components/icons'; +import { UserToolSection } from './UserToolSection'; +import { UserToolModal } from './UserToolModal'; +import type { + UserTool, + AddUserToolInput, +} from '../../../../graphql/user/userTool'; +import { useToastNotification } from '../../../../hooks/useToastNotification'; +import { usePrompt } from '../../../../hooks/usePrompt'; + +interface ProfileUserToolsProps { + user: PublicProfile; +} + +// Predefined category order +const CATEGORY_ORDER = [ + 'Development', + 'Design', + 'Productivity', + 'Communication', + 'AI', +]; + +export function ProfileUserTools({ + user, +}: ProfileUserToolsProps): ReactElement | null { + const { toolItems, groupedByCategory, isOwner, add, update, remove } = + useUserTools(user); + const { displayToast } = useToastNotification(); + const { showPrompt } = usePrompt(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + + const handleAdd = useCallback( + async (input: AddUserToolInput) => { + try { + await add(input); + displayToast('Added to your tools'); + } catch (error) { + displayToast('Failed to add tool'); + throw error; + } + }, + [add, displayToast], + ); + + const handleEdit = useCallback((item: UserTool) => { + setEditingItem(item); + setIsModalOpen(true); + }, []); + + const handleUpdate = useCallback( + async (input: AddUserToolInput) => { + if (!editingItem) { + return; + } + try { + await update({ + id: editingItem.id, + input: { + category: input.category, + }, + }); + displayToast('Tool updated'); + } catch (error) { + displayToast('Failed to update tool'); + throw error; + } + }, + [editingItem, update, displayToast], + ); + + const handleDelete = useCallback( + async (item: UserTool) => { + const confirmed = await showPrompt({ + title: 'Remove tool?', + description: `Are you sure you want to remove "${item.tool.title}" from your tools?`, + okButton: { title: 'Remove', variant: ButtonVariant.Primary }, + }); + if (!confirmed) { + return; + } + + try { + await remove(item.id); + displayToast('Removed from your tools'); + } catch (error) { + displayToast('Failed to remove tool'); + } + }, + [remove, displayToast, showPrompt], + ); + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false); + setEditingItem(null); + }, []); + + // Sort categories: predefined first, then custom alphabetically + const sortedCategories = Object.keys(groupedByCategory).sort((a, b) => { + const aIndex = CATEGORY_ORDER.indexOf(a); + const bIndex = CATEGORY_ORDER.indexOf(b); + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex; + } + if (aIndex !== -1) { + return -1; + } + if (bIndex !== -1) { + return 1; + } + return a.localeCompare(b); + }); + + const hasItems = toolItems.length > 0; + + if (!hasItems && !isOwner) { + return null; + } + + return ( +
+
+ + Tools + + {isOwner && ( + + )} +
+ + {hasItems ? ( +
+ {sortedCategories.map((category) => ( + + ))} +
+ ) : ( + isOwner && ( +
+
+ +
+ + Share the tools you use with the community + + +
+ ) + )} + + {isModalOpen && ( + + )} +
+ ); +} diff --git a/packages/shared/src/features/profile/components/tools/UserToolItem.tsx b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx new file mode 100644 index 0000000000..46d0f14c12 --- /dev/null +++ b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx @@ -0,0 +1,89 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { UserTool } from '../../../../graphql/user/userTool'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { EditIcon, TrashIcon } from '../../../../components/icons'; + +interface UserToolItemProps { + item: UserTool; + isOwner: boolean; + onEdit?: (item: UserTool) => void; + onDelete?: (item: UserTool) => void; +} + +export function UserToolItem({ + item, + isOwner, + onEdit, + onDelete, +}: UserToolItemProps): ReactElement { + const { tool } = item; + + return ( +
+ {tool.faviconUrl && ( + + )} +
+ + {tool.title} + + {tool.url && ( + + {new URL(tool.url).hostname} + + )} +
+ {isOwner && ( +
+ {onEdit && ( +
+ )} +
+ ); +} diff --git a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx new file mode 100644 index 0000000000..e81f82eb56 --- /dev/null +++ b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx @@ -0,0 +1,267 @@ +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 { + Typography, + TypographyType, +} from '../../../../components/typography/Typography'; +import { Button, ButtonVariant } from '../../../../components/buttons/Button'; +import { ModalHeader } from '../../../../components/modals/common/ModalHeader'; +import { useViewSize, ViewSize } from '../../../../hooks'; +import type { + UserTool, + AddUserToolInput, + DatasetTool, +} from '../../../../graphql/user/userTool'; +import { useToolSearch } from '../../hooks/useToolSearch'; + +const CATEGORY_OPTIONS = [ + 'Development', + 'Design', + 'Productivity', + 'Communication', + 'AI', +] as const; + +const userToolFormSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), + url: z.string().url().max(2000).optional().or(z.literal('')), + category: z.string().min(1, 'Category is required').max(100), + customCategory: z.string().max(100).optional(), +}); + +type UserToolFormData = z.infer; + +type UserToolModalProps = Omit & { + onSubmit: (input: AddUserToolInput) => Promise; + existingItem?: UserTool; +}; + +export function UserToolModal({ + onSubmit, + existingItem, + ...rest +}: UserToolModalProps): ReactElement { + const [showSuggestions, setShowSuggestions] = useState(false); + const isMobile = useViewSize(ViewSize.MobileL); + const isEditing = !!existingItem; + + const methods = useForm({ + resolver: zodResolver(userToolFormSchema), + defaultValues: { + title: existingItem?.tool.title ?? '', + url: existingItem?.tool.url ?? '', + category: existingItem?.category || 'Development', + customCategory: '', + }, + }); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = methods; + + const title = watch('title'); + const category = watch('category'); + const customCategory = watch('customCategory'); + + const { results: suggestions } = useToolSearch(title); + + const isCustomCategory = !CATEGORY_OPTIONS.includes( + category as (typeof CATEGORY_OPTIONS)[number], + ); + const finalCategory = isCustomCategory + ? customCategory || category + : category; + const canSubmit = title.trim().length > 0 && finalCategory.trim().length > 0; + + const handleSelectSuggestion = (suggestion: DatasetTool) => { + setValue('title', suggestion.title); + if (suggestion.url) { + setValue('url', suggestion.url); + } + setShowSuggestions(false); + }; + + const onFormSubmit = handleSubmit(async (data) => { + const effectiveCategory = isCustomCategory + ? data.customCategory || data.category + : data.category; + + await onSubmit({ + title: data.title.trim(), + url: data.url || undefined, + category: effectiveCategory.trim(), + }); + rest.onRequestClose?.(null); + }); + + const filteredSuggestions = useMemo(() => { + if (!showSuggestions || title.length < 1) { + return []; + } + return suggestions.filter( + (s) => s.title.toLowerCase() !== title.toLowerCase(), + ); + }, [suggestions, showSuggestions, title]); + + return ( + + + + {isEditing ? 'Edit Tool' : 'Add Tool'} + + + ), + rightButtonProps: { + variant: ButtonVariant.Primary, + disabled: !canSubmit || isSubmitting, + loading: isSubmitting, + }, + copy: { right: isEditing ? 'Save' : 'Add' }, + }} + kind={Modal.Kind.FlexibleCenter} + size={Modal.Size.Small} + {...rest} + > +
+ + + {isEditing ? 'Edit Tool' : 'Add Tool'} + + + + {/* Title with autocomplete */} +
+ { + setValue('title', e.target.value); + if (!isEditing) { + setShowSuggestions(true); + } + }} + onFocus={() => { + if (!isEditing) { + setShowSuggestions(true); + } + }} + /> + {!isEditing && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((suggestion) => ( + + ))} +
+ )} +
+ + {/* URL field */} + + + {/* Category selector */} +
+ + Category + +
+ {CATEGORY_OPTIONS.map((opt) => ( + + ))} + +
+ {isCustomCategory && ( + + )} +
+ + {!isMobile && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/shared/src/features/profile/components/tools/UserToolSection.tsx b/packages/shared/src/features/profile/components/tools/UserToolSection.tsx new file mode 100644 index 0000000000..8d0cd76ce2 --- /dev/null +++ b/packages/shared/src/features/profile/components/tools/UserToolSection.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { UserTool } from '../../../../graphql/user/userTool'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { Pill, PillSize } from '../../../../components/Pill'; +import { UserToolItem } from './UserToolItem'; + +interface UserToolSectionProps { + category: string; + items: UserTool[]; + isOwner: boolean; + onEdit?: (item: UserTool) => void; + onDelete?: (item: UserTool) => void; +} + +export function UserToolSection({ + category, + items, + isOwner, + onEdit, + onDelete, +}: UserToolSectionProps): ReactElement { + return ( +
+
+ + {category} + + +
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/packages/shared/src/features/profile/hooks/useToolSearch.ts b/packages/shared/src/features/profile/hooks/useToolSearch.ts new file mode 100644 index 0000000000..c08f4220a0 --- /dev/null +++ b/packages/shared/src/features/profile/hooks/useToolSearch.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import type { DatasetTool } from '../../../graphql/user/userTool'; +import { searchTools } from '../../../graphql/user/userTool'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; + +export function useToolSearch(query: string) { + const trimmedQuery = query.trim(); + const enabled = trimmedQuery.length >= 1; + + const queryKey = generateQueryKey(RequestKey.ToolSearch, null, trimmedQuery); + + const searchQuery = useQuery({ + queryKey, + queryFn: () => searchTools(trimmedQuery), + staleTime: StaleTime.Default, + enabled, + }); + + return { + ...searchQuery, + results: searchQuery.data ?? [], + isSearching: searchQuery.isFetching, + }; +} diff --git a/packages/shared/src/features/profile/hooks/useUserTools.ts b/packages/shared/src/features/profile/hooks/useUserTools.ts new file mode 100644 index 0000000000..b2ec402537 --- /dev/null +++ b/packages/shared/src/features/profile/hooks/useUserTools.ts @@ -0,0 +1,91 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useCallback } from 'react'; +import type { PublicProfile } from '../../../lib/user'; +import type { + UserTool, + AddUserToolInput, + UpdateUserToolInput, + ReorderUserToolInput, +} from '../../../graphql/user/userTool'; +import { + getUserTools, + addUserTool, + updateUserTool, + deleteUserTool, + reorderUserTools, +} from '../../../graphql/user/userTool'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { useAuthContext } from '../../../contexts/AuthContext'; + +export function useUserTools(user: PublicProfile | null) { + const queryClient = useQueryClient(); + const { user: loggedUser } = useAuthContext(); + const isOwner = loggedUser?.id === user?.id; + + const queryKey = generateQueryKey(RequestKey.UserTools, user, 'profile'); + + const query = useQuery({ + queryKey, + queryFn: () => getUserTools(user?.id as string), + staleTime: StaleTime.Default, + enabled: !!user?.id, + }); + + const toolItems = useMemo( + () => query.data?.edges?.map(({ node }) => node) ?? [], + [query.data], + ); + + // Group items by category + const groupedByCategory = useMemo(() => { + const groups: Record = {}; + toolItems.forEach((item) => { + if (!groups[item.category]) { + groups[item.category] = []; + } + groups[item.category].push(item); + }); + return groups; + }, [toolItems]); + + const invalidateQuery = useCallback(() => { + queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + const addMutation = useMutation({ + mutationFn: (input: AddUserToolInput) => addUserTool(input), + onSuccess: invalidateQuery, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateUserToolInput }) => + updateUserTool(id, input), + onSuccess: invalidateQuery, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteUserTool(id), + onSuccess: invalidateQuery, + }); + + const reorderMutation = useMutation({ + mutationFn: (items: ReorderUserToolInput[]) => reorderUserTools(items), + onSuccess: invalidateQuery, + }); + + return { + ...query, + toolItems, + groupedByCategory, + isOwner, + queryKey, + add: addMutation.mutateAsync, + update: updateMutation.mutateAsync, + remove: deleteMutation.mutateAsync, + reorder: reorderMutation.mutateAsync, + isAdding: addMutation.isPending, + isUpdating: updateMutation.isPending, + isDeleting: deleteMutation.isPending, + isReordering: reorderMutation.isPending, + }; +} diff --git a/packages/shared/src/graphql/user/userTool.ts b/packages/shared/src/graphql/user/userTool.ts new file mode 100644 index 0000000000..1184540a7b --- /dev/null +++ b/packages/shared/src/graphql/user/userTool.ts @@ -0,0 +1,160 @@ +import { gql } from 'graphql-request'; +import type { Connection } from '../common'; +import { gqlClient } from '../common'; + +export interface DatasetTool { + id: string; + title: string; + url: string | null; + faviconUrl: string | null; +} + +export interface UserTool { + id: string; + tool: DatasetTool; + category: string; + position: number; + createdAt: string; +} + +export interface AddUserToolInput { + title: string; + url?: string; + category: string; +} + +export interface UpdateUserToolInput { + category?: string; +} + +export interface ReorderUserToolInput { + id: string; + position: number; +} + +const USER_TOOL_FRAGMENT = gql` + fragment UserToolFragment on UserTool { + id + category + position + createdAt + tool { + id + title + url + faviconUrl + } + } +`; + +const USER_TOOLS_QUERY = gql` + query UserTools($userId: ID!, $first: Int, $after: String) { + userTools(userId: $userId, first: $first, after: $after) { + edges { + node { + ...UserToolFragment + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + ${USER_TOOL_FRAGMENT} +`; + +const SEARCH_TOOLS_QUERY = gql` + query SearchTools($query: String!) { + searchTools(query: $query) { + id + title + url + faviconUrl + } + } +`; + +const ADD_USER_TOOL_MUTATION = gql` + mutation AddUserTool($input: AddUserToolInput!) { + addUserTool(input: $input) { + ...UserToolFragment + } + } + ${USER_TOOL_FRAGMENT} +`; + +const UPDATE_USER_TOOL_MUTATION = gql` + mutation UpdateUserTool($id: ID!, $input: UpdateUserToolInput!) { + updateUserTool(id: $id, input: $input) { + ...UserToolFragment + } + } + ${USER_TOOL_FRAGMENT} +`; + +const DELETE_USER_TOOL_MUTATION = gql` + mutation DeleteUserTool($id: ID!) { + deleteUserTool(id: $id) { + _ + } + } +`; + +const REORDER_USER_TOOLS_MUTATION = gql` + mutation ReorderUserTools($items: [ReorderUserToolInput!]!) { + reorderUserTools(items: $items) { + ...UserToolFragment + } + } + ${USER_TOOL_FRAGMENT} +`; + +export const getUserTools = async ( + userId: string, + first = 50, +): Promise> => { + const result = await gqlClient.request<{ + userTools: Connection; + }>(USER_TOOLS_QUERY, { userId, first }); + return result.userTools; +}; + +export const searchTools = async (query: string): Promise => { + const result = await gqlClient.request<{ + searchTools: DatasetTool[]; + }>(SEARCH_TOOLS_QUERY, { query }); + return result.searchTools; +}; + +export const addUserTool = async ( + input: AddUserToolInput, +): Promise => { + const result = await gqlClient.request<{ + addUserTool: UserTool; + }>(ADD_USER_TOOL_MUTATION, { input }); + return result.addUserTool; +}; + +export const updateUserTool = async ( + id: string, + input: UpdateUserToolInput, +): Promise => { + const result = await gqlClient.request<{ + updateUserTool: UserTool; + }>(UPDATE_USER_TOOL_MUTATION, { id, input }); + return result.updateUserTool; +}; + +export const deleteUserTool = async (id: string): Promise => { + await gqlClient.request(DELETE_USER_TOOL_MUTATION, { id }); +}; + +export const reorderUserTools = async ( + items: ReorderUserToolInput[], +): Promise => { + const result = await gqlClient.request<{ + reorderUserTools: UserTool[]; + }>(REORDER_USER_TOOLS_MUTATION, { items }); + return result.reorderUserTools; +}; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 03aca88b51..7ecca842e4 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -231,6 +231,8 @@ export enum RequestKey { UserStack = 'user_stack', StackSearch = 'stack_search', UserHotTakes = 'user_hot_takes', + UserTools = 'user_tools', + ToolSearch = 'tool_search', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index 3f2cf7cd6d..21535b893d 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -10,6 +10,7 @@ import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHea import { AutofillProfileBanner } from '@dailydotdev/shared/src/features/profile/components/AutofillProfileBanner'; import { ProfileUserExperiences } from '@dailydotdev/shared/src/features/profile/components/experience/ProfileUserExperiences'; import { ProfileUserStack } from '@dailydotdev/shared/src/features/profile/components/stack/ProfileUserStack'; +import { ProfileUserTools } from '@dailydotdev/shared/src/features/profile/components/tools/ProfileUserTools'; import { ProfileUserHotTakes } from '@dailydotdev/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes'; import { useUploadCv } from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; @@ -84,6 +85,7 @@ const ProfilePage = ({ {!shouldShowBanner &&
} + {isUserSame && ( From b34ceb34ab3415882344a32245fe21c3d9788249 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 11:20:36 +0200 Subject: [PATCH 2/7] feat(profile): update tools/stack to use unified dataset with auto-fetched icons - Remove url field from tool input form - Remove emoji picker from stack modal (icons auto-fetched now) - Update UserStack to use tool relation instead of stack - Update GraphQL queries to use DatasetTool for both tools and stack - Display faviconUrl as image in suggestion dropdowns Co-Authored-By: Claude Opus 4.5 --- .../components/stack/ProfileUserStack.tsx | 2 +- .../components/stack/UserStackModal.tsx | 41 +++++-------------- .../components/tools/UserToolModal.tsx | 18 -------- .../features/profile/hooks/useStackSearch.ts | 4 +- packages/shared/src/graphql/user/userStack.ts | 20 ++++----- packages/shared/src/graphql/user/userTool.ts | 4 -- 6 files changed, 20 insertions(+), 69 deletions(-) diff --git a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx index 0a9fe9eab2..bc07a04be4 100644 --- a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx +++ b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx @@ -84,7 +84,7 @@ export function ProfileUserStack({ const handleDelete = useCallback( async (item: UserStack) => { - const displayTitle = item.title ?? item.stack.title; + const displayTitle = item.title ?? item.tool.title; const confirmed = await showPrompt({ title: 'Remove from stack?', description: `Are you sure you want to remove "${displayTitle}" from your stack?`, diff --git a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx index b9fb24bca6..8cb2a15bd8 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; -import dynamic from 'next/dynamic'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; import { TextField } from '../../../../components/fields/TextField'; @@ -17,27 +16,18 @@ import { useViewSize, ViewSize } from '../../../../hooks'; import type { UserStack, AddUserStackInput, - DatasetStack, } from '../../../../graphql/user/userStack'; +import type { DatasetTool } from '../../../../graphql/user/userTool'; import { useStackSearch } from '../../hooks/useStackSearch'; import YearSelect from '../../../../components/profile/YearSelect'; import MonthSelect from '../../../../components/profile/MonthSelect'; -const EmojiPicker = dynamic( - () => - import('../../../../components/fields/EmojiPicker').then( - (mod) => mod.EmojiPicker, - ), - { ssr: false }, -); - const SECTION_OPTIONS = ['Primary', 'Hobby', 'Learning', 'Past'] as const; const userStackFormSchema = z.object({ title: z.string().min(1, 'Title is required').max(255), section: z.string().min(1, 'Section is required').max(100), customSection: z.string().max(100).optional(), - icon: z.string().max(50).optional(), startedAtYear: z.string().optional(), startedAtMonth: z.string().optional(), }); @@ -61,10 +51,9 @@ export function UserStackModal({ const methods = useForm({ resolver: zodResolver(userStackFormSchema), defaultValues: { - title: existingItem?.title ?? existingItem?.stack.title ?? '', + title: existingItem?.title ?? existingItem?.tool.title ?? '', section: existingItem?.section || 'Primary', customSection: '', - icon: existingItem?.icon ?? existingItem?.stack.icon ?? '', startedAtYear: existingItem?.startedAt ? new Date(existingItem.startedAt).getUTCFullYear().toString() : '', @@ -96,11 +85,8 @@ export function UserStackModal({ const finalSection = isCustomSection ? customSection || section : section; const canSubmit = title.trim().length > 0 && finalSection.trim().length > 0; - const handleSelectSuggestion = (suggestion: DatasetStack) => { + const handleSelectSuggestion = (suggestion: DatasetTool) => { setValue('title', suggestion.title); - if (suggestion.icon) { - setValue('icon', suggestion.icon); - } setShowSuggestions(false); }; @@ -120,7 +106,6 @@ export function UserStackModal({ await onSubmit({ title: data.title.trim(), section: effectiveSection.trim(), - icon: data.icon || undefined, startedAt: startedAtValue, }); rest.onRequestClose?.(null); @@ -197,7 +182,13 @@ export function UserStackModal({ className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-surface-hover" onClick={() => handleSelectSuggestion(suggestion)} > - {suggestion.icon && {suggestion.icon}} + {suggestion.faviconUrl && ( + + )} {suggestion.title} ))} @@ -205,18 +196,6 @@ export function UserStackModal({ )}
- {/* Icon picker */} - ( - - )} - /> - {/* Section selector */}
diff --git a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx index e81f82eb56..48ae40d529 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx @@ -30,7 +30,6 @@ const CATEGORY_OPTIONS = [ const userToolFormSchema = z.object({ title: z.string().min(1, 'Title is required').max(255), - url: z.string().url().max(2000).optional().or(z.literal('')), category: z.string().min(1, 'Category is required').max(100), customCategory: z.string().max(100).optional(), }); @@ -55,7 +54,6 @@ export function UserToolModal({ resolver: zodResolver(userToolFormSchema), defaultValues: { title: existingItem?.tool.title ?? '', - url: existingItem?.tool.url ?? '', category: existingItem?.category || 'Development', customCategory: '', }, @@ -85,9 +83,6 @@ export function UserToolModal({ const handleSelectSuggestion = (suggestion: DatasetTool) => { setValue('title', suggestion.title); - if (suggestion.url) { - setValue('url', suggestion.url); - } setShowSuggestions(false); }; @@ -98,7 +93,6 @@ export function UserToolModal({ await onSubmit({ title: data.title.trim(), - url: data.url || undefined, category: effectiveCategory.trim(), }); rest.onRequestClose?.(null); @@ -189,18 +183,6 @@ export function UserToolModal({ )}
- {/* URL field */} - - {/* Category selector */}
diff --git a/packages/shared/src/features/profile/hooks/useStackSearch.ts b/packages/shared/src/features/profile/hooks/useStackSearch.ts index b40009cd24..790c2a31ac 100644 --- a/packages/shared/src/features/profile/hooks/useStackSearch.ts +++ b/packages/shared/src/features/profile/hooks/useStackSearch.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import type { DatasetStack } from '../../../graphql/user/userStack'; +import type { DatasetTool } from '../../../graphql/user/userTool'; import { searchStack } from '../../../graphql/user/userStack'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; @@ -9,7 +9,7 @@ export function useStackSearch(query: string) { const queryKey = generateQueryKey(RequestKey.StackSearch, null, trimmedQuery); - const searchQuery = useQuery({ + const searchQuery = useQuery({ queryKey, queryFn: () => searchStack(trimmedQuery), staleTime: StaleTime.Default, diff --git a/packages/shared/src/graphql/user/userStack.ts b/packages/shared/src/graphql/user/userStack.ts index 18a20a1cb4..6cb4862ab1 100644 --- a/packages/shared/src/graphql/user/userStack.ts +++ b/packages/shared/src/graphql/user/userStack.ts @@ -1,16 +1,11 @@ import { gql } from 'graphql-request'; import type { Connection } from '../common'; import { gqlClient } from '../common'; - -export interface DatasetStack { - id: string; - title: string; - icon: string | null; -} +import type { DatasetTool } from './userTool'; export interface UserStack { id: string; - stack: DatasetStack; + tool: DatasetTool; section: string; position: number; startedAt: string | null; @@ -22,7 +17,6 @@ export interface UserStack { export interface AddUserStackInput { title: string; section: string; - icon?: string; startedAt?: string; } @@ -47,10 +41,10 @@ const USER_STACK_FRAGMENT = gql` icon title createdAt - stack { + tool { id title - icon + faviconUrl } } `; @@ -77,7 +71,7 @@ const SEARCH_STACK_QUERY = gql` searchStack(query: $query) { id title - icon + faviconUrl } } `; @@ -127,9 +121,9 @@ export const getUserStack = async ( return result.userStack; }; -export const searchStack = async (query: string): Promise => { +export const searchStack = async (query: string): Promise => { const result = await gqlClient.request<{ - searchStack: DatasetStack[]; + searchStack: DatasetTool[]; }>(SEARCH_STACK_QUERY, { query }); return result.searchStack; }; diff --git a/packages/shared/src/graphql/user/userTool.ts b/packages/shared/src/graphql/user/userTool.ts index 1184540a7b..2a0412fc4a 100644 --- a/packages/shared/src/graphql/user/userTool.ts +++ b/packages/shared/src/graphql/user/userTool.ts @@ -5,7 +5,6 @@ import { gqlClient } from '../common'; export interface DatasetTool { id: string; title: string; - url: string | null; faviconUrl: string | null; } @@ -19,7 +18,6 @@ export interface UserTool { export interface AddUserToolInput { title: string; - url?: string; category: string; } @@ -41,7 +39,6 @@ const USER_TOOL_FRAGMENT = gql` tool { id title - url faviconUrl } } @@ -69,7 +66,6 @@ const SEARCH_TOOLS_QUERY = gql` searchTools(query: $query) { id title - url faviconUrl } } From 5a83082e6e9be9d5b66c02117a30b6a5b8180e36 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 12:10:12 +0200 Subject: [PATCH 3/7] feat(profile): update stack/tools to use autocompleteTools query - Update userTool.ts to call autocompleteTools instead of searchTools - Re-export searchTools as searchStack in userStack.ts for compatibility - Update UserStackItem to use tool.faviconUrl instead of stack.icon - Only render favicon image when URL exists Co-Authored-By: Claude Opus 4.5 --- .../components/stack/UserStackItem.tsx | 21 +++++++------------ packages/shared/src/graphql/user/userStack.ts | 18 ++-------------- packages/shared/src/graphql/user/userTool.ts | 12 +++++------ 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx index 75a302e6e8..2610340056 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx @@ -6,7 +6,6 @@ import { Typography, TypographyType, TypographyColor, - TypographyTag, } from '../../../../components/typography/Typography'; import { Button, @@ -29,10 +28,8 @@ export function UserStackItem({ onEdit, onDelete, }: UserStackItemProps): ReactElement { - const { stack, startedAt } = item; - // User's title/icon overrides dataset values - const icon = item.icon ?? stack.icon; - const title = item.title ?? stack.title; + const { tool, startedAt } = item; + const title = item.title ?? tool.title; const usingSince = startedAt ? `Since ${formatMonthYearOnly(new Date(startedAt))}` @@ -46,14 +43,12 @@ export function UserStackItem({ )} >
- {icon && ( - - {icon} - + {tool.faviconUrl && ( + )}
=> { - const result = await gqlClient.request<{ - searchStack: DatasetTool[]; - }>(SEARCH_STACK_QUERY, { query }); - return result.searchStack; -}; +// Re-export searchTools as searchStack for backwards compatibility +export { searchTools as searchStack } from './userTool'; export const addUserStack = async ( input: AddUserStackInput, diff --git a/packages/shared/src/graphql/user/userTool.ts b/packages/shared/src/graphql/user/userTool.ts index 2a0412fc4a..325f3ef92d 100644 --- a/packages/shared/src/graphql/user/userTool.ts +++ b/packages/shared/src/graphql/user/userTool.ts @@ -61,9 +61,9 @@ const USER_TOOLS_QUERY = gql` ${USER_TOOL_FRAGMENT} `; -const SEARCH_TOOLS_QUERY = gql` - query SearchTools($query: String!) { - searchTools(query: $query) { +const AUTOCOMPLETE_TOOLS_QUERY = gql` + query AutocompleteTools($query: String!) { + autocompleteTools(query: $query) { id title faviconUrl @@ -118,9 +118,9 @@ export const getUserTools = async ( export const searchTools = async (query: string): Promise => { const result = await gqlClient.request<{ - searchTools: DatasetTool[]; - }>(SEARCH_TOOLS_QUERY, { query }); - return result.searchTools; + autocompleteTools: DatasetTool[]; + }>(AUTOCOMPLETE_TOOLS_QUERY, { query }); + return result.autocompleteTools; }; export const addUserTool = async ( From 69b4cdb5dc9925a23e2145c7a052977ea444699b Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 13:27:02 +0200 Subject: [PATCH 4/7] fix: build issues --- .../profile/components/stack/ProfileUserStack.tsx | 1 - .../features/profile/components/tools/UserToolItem.tsx | 9 --------- 2 files changed, 10 deletions(-) diff --git a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx index bc07a04be4..48ae85f5b7 100644 --- a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx +++ b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx @@ -68,7 +68,6 @@ export function ProfileUserStack({ id: editingItem.id, input: { section: input.section, - icon: input.icon, title: input.title, startedAt: input.startedAt || null, }, diff --git a/packages/shared/src/features/profile/components/tools/UserToolItem.tsx b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx index 46d0f14c12..bcb8405962 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolItem.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx @@ -52,15 +52,6 @@ export function UserToolItem({ > {tool.title} - {tool.url && ( - - {new URL(tool.url).hostname} - - )}
{isOwner && (
From 1e8a493e89d922a5f0219f2a1ecf712dd90360bd Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 13:27:02 +0200 Subject: [PATCH 5/7] fix: build issues --- .../profile/components/stack/ProfileUserStack.tsx | 1 - .../features/profile/components/tools/UserToolItem.tsx | 9 --------- .../features/profile/components/tools/UserToolModal.tsx | 5 ++--- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx index bc07a04be4..48ae85f5b7 100644 --- a/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx +++ b/packages/shared/src/features/profile/components/stack/ProfileUserStack.tsx @@ -68,7 +68,6 @@ export function ProfileUserStack({ id: editingItem.id, input: { section: input.section, - icon: input.icon, title: input.title, startedAt: input.startedAt || null, }, diff --git a/packages/shared/src/features/profile/components/tools/UserToolItem.tsx b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx index 46d0f14c12..bcb8405962 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolItem.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolItem.tsx @@ -52,15 +52,6 @@ export function UserToolItem({ > {tool.title} - {tool.url && ( - - {new URL(tool.url).hostname} - - )}
{isOwner && (
diff --git a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx index 48ae40d529..a7c18a3899 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx @@ -72,6 +72,7 @@ export function UserToolModal({ const customCategory = watch('customCategory'); const { results: suggestions } = useToolSearch(title); + console.log(suggestions) const isCustomCategory = !CATEGORY_OPTIONS.includes( category as (typeof CATEGORY_OPTIONS)[number], @@ -102,9 +103,7 @@ export function UserToolModal({ if (!showSuggestions || title.length < 1) { return []; } - return suggestions.filter( - (s) => s.title.toLowerCase() !== title.toLowerCase(), - ); + return suggestions; }, [suggestions, showSuggestions, title]); return ( From 96579b506648abaa202ea605843fb54e0169f48a Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 15:09:40 +0200 Subject: [PATCH 6/7] fiX: lint --- .../src/features/profile/components/stack/UserStackItem.tsx | 2 +- .../src/features/profile/components/stack/UserStackModal.tsx | 2 +- .../src/features/profile/components/tools/UserToolModal.tsx | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx index ce14c6c583..a4f9270dc5 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx @@ -47,7 +47,7 @@ export function UserStackItem({ )}
diff --git a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx index 8cb2a15bd8..08c604d772 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx @@ -186,7 +186,7 @@ export function UserStackModal({ )} {suggestion.title} diff --git a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx index a7c18a3899..85ab8c743b 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx @@ -72,7 +72,6 @@ export function UserToolModal({ const customCategory = watch('customCategory'); const { results: suggestions } = useToolSearch(title); - console.log(suggestions) const isCustomCategory = !CATEGORY_OPTIONS.includes( category as (typeof CATEGORY_OPTIONS)[number], From c04134406db740fc8a51bba2d1c0c3de6bd8e2de Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 16:46:48 +0200 Subject: [PATCH 7/7] fix: dissallow tool changing --- .../src/features/profile/components/stack/UserStackModal.tsx | 1 + .../src/features/profile/components/tools/UserToolModal.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx index 08c604d772..a7913cb2de 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackModal.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackModal.tsx @@ -161,6 +161,7 @@ export function UserStackModal({ maxLength={255} valid={!errors.title} hint={errors.title?.message} + disabled={isEditing} onChange={(e) => { setValue('title', e.target.value); if (!isEditing) { diff --git a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx index 85ab8c743b..0fb9519f64 100644 --- a/packages/shared/src/features/profile/components/tools/UserToolModal.tsx +++ b/packages/shared/src/features/profile/components/tools/UserToolModal.tsx @@ -146,6 +146,7 @@ export function UserToolModal({ maxLength={255} valid={!errors.title} hint={errors.title?.message} + disabled={isEditing} onChange={(e) => { setValue('title', e.target.value); if (!isEditing) {