feat: enhance person management with merge functionality

- Added merge capability for persons in the PersonItem component using an autocomplete input.
- Introduced an AlertDialog for confirming merges.
- Updated package.json to include new dependencies for react-query and related types.
- Refactored FindInput component by removing unused styles.
- Updated AlertDialog interface for better type handling.
This commit is contained in:
Varun Raj
2025-03-25 21:25:59 +05:30
parent 5db6e414a1
commit 57c9bd2070
9 changed files with 352 additions and 28 deletions
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -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",
-2
View File
@@ -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;
+44 -18
View File
@@ -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<AutocompleteOption | null>(null);
const mergeDialogRef = useRef<IAlertDialogActions>(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 (
<div
className={clsx(
@@ -79,11 +87,11 @@ export default function PersonItem({ person, onRemove }: IProps) {
)}
>
<div className="relative w-full h-auto group">
<Avatar
className="w-full min-h-full h-auto rounded-lg"
src={person.thumbnailPath}
alt={person.name}
/>
<Avatar
className="w-full min-h-full h-auto rounded-lg"
src={person.thumbnailPath}
alt={person.name}
/>
<div className="absolute bottom-2 w-full flex justify-center items-center">
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
</div>
@@ -103,13 +111,13 @@ export default function PersonItem({ person, onRemove }: IProps) {
</Link>
</div>
<div className="absolute top-2 right-2 flex flex-col gap-2">
<PersonMergeDropdown person={person} onRemove={onRemove}/>
<PersonMergeDropdown person={person} onRemove={onRemove} />
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
handleHide(!formData.isHidden);
}}>
{formData.isHidden ? "Show" : "Hide"}
</Button>
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
{formData.isHidden ? "Show" : "Hide"}
</Button>
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
</div>
</div>
{!editMode ? (
@@ -126,7 +134,11 @@ export default function PersonItem({ person, onRemove }: IProps) {
)}
</h2>
) : (
<input
<Autocomplete
loadOptions={(query: string) => 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();
}}
/>
)}
<div className="flex flex-col gap-2">
<PersonBirthdayCell person={person} />
</div>
<AlertDialog
ref={mergeDialogRef}
title="Merge Person"
description="Are you sure you want to merge this person with the selected person?"
onConfirm={() => {
if (selectedPerson.current) {
handleMerge(selectedPerson.current);
}
}}
/>
</div>
);
}
+2 -2
View File
@@ -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<INewChangelogButtonActions, Partial<AlertDialogProps>>(({
const AlertDialog = React.forwardRef<IAlertDialogActions, Partial<AlertDialogProps>>(({
children,
title,
description,
+275
View File
@@ -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<HTMLInputElement> {
options?: AutocompleteOption[]
value: string
onOptionSelect: (value: AutocompleteOption) => void
placeholder?: string
className?: string
loadOptions?: (inputValue: string) => Promise<AutocompleteOption[]>
initialValue?: string
isLoading?: boolean
onCreateNew?: (inputValue: string) => void
createNewLabel?: string
}
const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
({
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<AutocompleteOption[]>(staticOptions || [])
const [isLoading, setIsLoading] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState(-1)
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const debounceTimeout = React.useRef<NodeJS.Timeout>()
// 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<HTMLInputElement | HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className="relative w-full"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<Input
ref={(node) => {
// Handle both the forwarded ref and our internal ref
if (typeof forwardedRef === 'function') {
forwardedRef(node)
}
if (node) {
(inputRef as React.MutableRefObject<HTMLInputElement | null>).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) && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md">
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
Loading...
</div>
) : (
<>
{filteredOptions.map((option, index) => (
<button
key={option.value}
className={cn(
"w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none flex items-center gap-2",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
onClick={() => {
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)
}}
>
{option.imageUrl && (
<Avatar className="w-6 h-6 rounded-full" src={option.imageUrl} alt={option.label} />
)}
<span className="truncate">
{option.label}
</span>
</button>
))}
{showCreateNew && (
<button
className={cn(
"w-full border-t px-3 py-2 text-left text-sm text-primary hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none",
selectedIndex === filteredOptions.length && "bg-accent text-accent-foreground"
)}
onClick={() => {
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)
}}
>
{createNewLabel} &quot;{inputValue.trim()}&quot;
</button>
)}
</>
)}
</div>
)}
</div>
)
}
)
Autocomplete.displayName = "Autocomplete"
export { Autocomplete }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+3
View File
@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();
+9 -5
View File
@@ -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 (
<ConfigContext.Provider value={intialData.current}>
<ThemeProvider attribute="class" storageKey="theme">
<RootLayout>
<Component {...pageProps} />
</RootLayout>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" storageKey="theme">
<RootLayout>
<Component {...pageProps} />
</RootLayout>
</ThemeProvider>
</QueryClientProvider>
</ConfigContext.Provider>
);
};