diff --git a/bun.lockb b/bun.lockb index 0b94ea6..c4de24e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c4869bc..f9e7cbf 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "@radix-ui/react-tooltip": "^1.1.2", "@remotion/cli": "4.0.232", "@remotion/eslint-plugin": "^4.0.232", + "@tanstack/react-query": "^5.69.0", "@tanstack/react-table": "^8.20.1", "@types/cookie": "^0.6.0", "@types/qs": "^6.9.15", + "@types/react-autosuggest": "^10.1.11", "@types/react-calendar-heatmap": "^1.6.7", "@vis.gl/react-google-maps": "^1.4.2", "axios": "^1.7.4", @@ -57,13 +59,13 @@ "pg": "^8.12.0", "qs": "^6.13.0", "react": "^18", + "react-autosuggest": "^10.1.0", "react-day-picker": "9.0.8", "react-dom": "^18", "react-grid-gallery": "^1.0.1", "react-hot-toast": "^2.5.1", "react-leaflet": "^4.2.1", "react-mentions": "^4.4.10", - "react-query": "^3.39.3", "recharts": "^2.12.7", "remotion": "4.0.232", "sass": "^1.77.8", @@ -78,6 +80,7 @@ "@semantic-release/github": "^10.1.7", "@semantic-release/npm": "^12.0.1", "@semantic-release/release-notes-generator": "^14.0.1", + "@tanstack/eslint-plugin-query": "^5.68.0", "@types/jsonwebtoken": "^9.0.6", "@types/leaflet": "^1.9.15", "@types/node": "^20", diff --git a/src/components/find/FindInput.tsx b/src/components/find/FindInput.tsx index 094985e..7b7bbf9 100644 --- a/src/components/find/FindInput.tsx +++ b/src/components/find/FindInput.tsx @@ -3,8 +3,6 @@ import inputStyle from './findInputStyle' import { MentionsInput, Mention } from 'react-mentions' import { searchPeople } from '@/handlers/api/people.handler'; import mentionStyle from './findMentionStyle'; -import classNames from './FindInput.module.css'; - interface FindInputProps { onSearch: (query: string) => void; diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx index a0dee00..c8cdba1 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -1,7 +1,7 @@ import { IPerson } from "@/types/person"; -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Avatar } from "../ui/avatar"; -import { updatePerson } from "@/handlers/api/people.handler"; +import { mergePerson, searchPeople, updatePerson } from "@/handlers/api/people.handler"; import { PersonMergeDropdown } from "./PersonMergeDropdown"; import PersonBirthdayCell from "./PersonBirthdayCell"; import clsx from "clsx"; @@ -12,6 +12,8 @@ import { useToast } from "../ui/use-toast"; import { Badge } from "../ui/badge"; import { Button } from "@/components/ui/button"; import ShareAssetsTrigger from "../shared/ShareAssetsTrigger"; +import { Autocomplete, AutocompleteOption } from "../ui/autocomplete"; +import { AlertDialog, IAlertDialogActions } from "../ui/alert-dialog"; interface IProps { person: IPerson; onRemove: (person: IPerson) => void; @@ -22,6 +24,8 @@ export default function PersonItem({ person, onRemove }: IProps) { const [editMode, setEditMode] = useState(false); const [loading, setLoading] = useState(false); const [formData, setFormData] = useState(person); + const selectedPerson = useRef(null); + const mergeDialogRef = useRef(null); const handleEdit = () => { if (formData.name && formData.name !== person.name) { @@ -62,12 +66,16 @@ export default function PersonItem({ person, onRemove }: IProps) { isHidden: hidden, })); }) - .catch(() => {}) + .catch(() => { }) .finally(() => { setLoading(false); }); }; + const handleMerge = async (option: AutocompleteOption) => { + await mergePerson(person.id, [option.value]); + }; + return (
- +
{person.assetCount} Assets
@@ -103,13 +111,13 @@ export default function PersonItem({ person, onRemove }: IProps) {
- + - + {formData.isHidden ? "Show" : "Hide"} + +
{!editMode ? ( @@ -126,7 +134,11 @@ export default function PersonItem({ person, onRemove }: IProps) { )} ) : ( - searchPeople(query).then((people) => people.map((person: any) => ({ + label: person.name, value: person.id, + imageUrl: person.thumbnailPath + })))} type="text" className="text-lg font-semibold text-center w-full px-2 py-1 rounded-lg" defaultValue={formData.name} @@ -135,18 +147,32 @@ export default function PersonItem({ person, onRemove }: IProps) { onChange={(e) => { setFormData({ ...formData, name: e.target.value }); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleEdit(); - } + value={formData.name} + onOptionSelect={(value) => { + mergeDialogRef.current?.open(); + selectedPerson.current = value; }} + createNewLabel="Merge with this person" disabled={loading} - onBlur={handleEdit} + onCreateNew={(value) => { + handleEdit(); + }} /> )}
+ + { + if (selectedPerson.current) { + handleMerge(selectedPerson.current); + } + }} + /> ); } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 35c8942..524494d 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -128,7 +128,7 @@ const AlertDialogCancel = React.forwardRef< AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName -export interface INewChangelogButtonActions { +export interface IAlertDialogActions { open: () => void } interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps { @@ -142,7 +142,7 @@ interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps { disabled?: boolean } -const AlertDialog = React.forwardRef>(({ +const AlertDialog = React.forwardRef>(({ children, title, description, diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx new file mode 100644 index 0000000..5fa48f3 --- /dev/null +++ b/src/components/ui/autocomplete.tsx @@ -0,0 +1,275 @@ +import * as React from "react" +import { Input } from "./input" +import { cn } from "@/lib/utils" +import { Avatar, AvatarImage } from "./avatar" + +export interface AutocompleteOption { + label: string + value: string + imageUrl?: string +} + +export interface AutocompleteProps extends React.InputHTMLAttributes { + options?: AutocompleteOption[] + value: string + onOptionSelect: (value: AutocompleteOption) => void + placeholder?: string + className?: string + loadOptions?: (inputValue: string) => Promise + initialValue?: string + isLoading?: boolean + onCreateNew?: (inputValue: string) => void + createNewLabel?: string +} + +const Autocomplete = React.forwardRef( + ({ + options: staticOptions, + value, + onOptionSelect, + placeholder, + className, + loadOptions, + initialValue, + isLoading: externalLoading, + onCreateNew, + createNewLabel = "Create new", + ...props + }, forwardedRef) => { + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState(initialValue || value) + const [options, setOptions] = React.useState(staticOptions || []) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedIndex, setSelectedIndex] = React.useState(-1) + const containerRef = React.useRef(null) + const inputRef = React.useRef(null) + const debounceTimeout = React.useRef() + + // Find the label for the current value + const currentLabel = React.useMemo(() => { + if (!value) return "" + const option = options.find(opt => opt.value === value) + return option?.label || value + }, [value, options]) + + React.useEffect(() => { + setInputValue(initialValue || currentLabel) + }, [initialValue, currentLabel]) + + // Close dropdown when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false) + setSelectedIndex(-1) + // Don't blur the input when clicking outside with a small delay + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, 0) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Handle async loading of options + React.useEffect(() => { + if (!loadOptions) return + + const loadOptionsAsync = async () => { + if (!inputValue.trim()) { + setOptions([]) + return + } + + setIsLoading(true) + try { + const results = await loadOptions(inputValue) + setOptions(results) + } catch (error) { + console.error('Error loading options:', error) + setOptions([]) + } finally { + setIsLoading(false) + } + } + + // Debounce the search to avoid too many API calls + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current) + } + debounceTimeout.current = setTimeout(loadOptionsAsync, 300) + + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current) + } + } + }, [inputValue, loadOptions]) + + const filteredOptions = loadOptions + ? options + : (staticOptions || []).filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + + const showCreateNew = onCreateNew && inputValue.trim() && !isLoading + const totalOptions = filteredOptions.length + (showCreateNew ? 1 : 0) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + setOpen(true) + setSelectedIndex(0) + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % totalOptions) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + totalOptions) % totalOptions) + break + case 'Enter': + e.preventDefault() + if (selectedIndex >= 0 && selectedIndex < filteredOptions.length) { + const option = filteredOptions[selectedIndex] + setInputValue(option.label) + onOptionSelect(option) + setOpen(false) + setSelectedIndex(-1) + // Keep focus on input after selection with a small delay + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, 0) + } else if (showCreateNew && selectedIndex === filteredOptions.length) { + onCreateNew(inputValue.trim()) + setOpen(false) + setSelectedIndex(-1) + // Keep focus on input after creating new with a small delay + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, 0) + } + break + case 'Escape': + e.preventDefault() + setOpen(false) + setSelectedIndex(-1) + break + } + } + + return ( +
+ { + // Handle both the forwarded ref and our internal ref + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } + if (node) { + (inputRef as React.MutableRefObject).current = node + } + }} + value={inputValue} + onFocus={(e) => { + setOpen(true) + // containerRef.current?.focus() + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className={cn("w-full", className)} + {...props} + onChange={(e) => { + setInputValue(e.target.value) + setSelectedIndex(-1) + props.onChange?.(e) + }} + /> + {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && ( +
+ {isLoading ? ( +
+ Loading... +
+ ) : ( + <> + {filteredOptions.map((option, index) => ( + + ))} + {showCreateNew && ( + + )} + + )} +
+ )} +
+ ) + } +) + +Autocomplete.displayName = "Autocomplete" + +export { Autocomplete } diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..d7e45f7 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/config/queryClient.ts b/src/config/queryClient.ts new file mode 100644 index 0000000..498e530 --- /dev/null +++ b/src/config/queryClient.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); \ No newline at end of file diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 649a4b7..4954850 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -5,6 +5,8 @@ import { ThemeProvider } from "next-themes"; import { ENV } from "@/config/environment"; import ConfigContext, { ConfigContextType } from "@/contexts/ConfigContext"; import { useRef } from "react"; +import { queryClient } from "@/config/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; interface AppPropsWithProps extends AppProps { props: ConfigContextType; } @@ -13,11 +15,13 @@ const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => { return ( - - - - - + + + + + + + ); };