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:
+4
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Autocomplete.displayName = "Autocomplete"
|
||||
|
||||
export { Autocomplete }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,3 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
+9
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user