feat: people card redesign - quick hide button, dropdown menu, grey border

This commit is contained in:
Varun
2026-04-08 13:34:31 +05:30
parent 36ea092ae9
commit 0ede3927f8
3 changed files with 83 additions and 56 deletions
+69 -53
View File
@@ -5,12 +5,20 @@ import { PersonMergeDropdown } from "./PersonMergeDropdown";
import PersonBirthdayCell from "./PersonBirthdayCell";
import clsx from "clsx";
import Link from "next/link";
import { ArrowUpRight, Eye, EyeOff, Info } from "lucide-react";
import { ArrowUpRight, Eye, EyeOff, GitMerge, Info, MoreHorizontal, Share2 } from "lucide-react";
import { useConfig } from "@/contexts/ConfigContext";
import { useToast } from "../ui/use-toast";
import ShareAssetsTrigger from "../shared/ShareAssetsTrigger";
import { Autocomplete, AutocompleteOption } from "../ui/autocomplete";
import { AlertDialog, IAlertDialogActions } from "../ui/alert-dialog";
import { Tooltip } from "../ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
interface IProps {
person: IPerson;
@@ -62,7 +70,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
"hover:shadow-lg hover:border-border/80",
{
"opacity-40": formData.isHidden,
"border-blue-500/60 shadow-blue-500/10 shadow-md": formData.name,
"border-border shadow-sm": formData.name,
"border-border/40": !formData.name,
}
)}
@@ -82,75 +90,83 @@ export default function PersonItem({ person, onRemove }: IProps) {
)}
{/* Gradient overlay — always visible at bottom */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
{/* Asset count — bottom of image */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<span className="text-[11px] font-medium text-white/90 tabular-nums tracking-wide">
{formData.isHidden && <span className="mr-1 opacity-70">Hidden ·</span>}
{person.assetCount} assets
{/* Asset count — bottom-left of image */}
<div className="absolute bottom-1.5 left-2 pointer-events-none">
<span className="text-[10px] font-medium text-white/80 tabular-nums">
{formData.isHidden && <span className="mr-0.5 opacity-70">Hidden ·</span>}
{person.assetCount}
</span>
</div>
{/* Action row — slides in from top on hover */}
<div className="absolute top-0 inset-x-0 flex items-center justify-between p-1.5 translate-y-[-100%] group-hover:translate-y-0 transition-transform duration-200 bg-gradient-to-b from-black/60 to-transparent">
{/* Left: immich + info links */}
<div className="flex items-center gap-1">
<Link
href={`${exImmichUrl}/people/${person.id}`}
target="_blank"
title="Open in Immich"
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight size={13} />
</Link>
<Link
href={`/people/${person.id}`}
title="View details"
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info size={13} />
</Link>
</div>
{/* Three-dot menu — top-right, visible on hover */}
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-1 rounded-full bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm transition-colors">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4} className="min-w-[160px]">
<DropdownMenuItem asChild>
<Link href={`/people/${person.id}`} className="gap-2">
<Info className="h-3.5 w-3.5" />
View details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`${exImmichUrl}/people/${person.id}`} target="_blank" className="gap-2">
<ArrowUpRight className="h-3.5 w-3.5" />
Open in Immich
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<ShareAssetsTrigger
filters={{ personIds: [person.id] }}
asDropdownItem
>
<Share2 className="h-3.5 w-3.5" />
Share assets
</ShareAssetsTrigger>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Right: hide + share + merge */}
<div className="flex items-center gap-1">
{/* Quick actions — bottom-right, visible on hover */}
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<Tooltip content={formData.isHidden ? "Show person" : "Hide person"}>
<button
title={formData.isHidden ? "Show person" : "Hide person"}
disabled={loading}
onClick={() => handleHide(!formData.isHidden)}
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors disabled:opacity-50"
disabled={loading}
className="h-6 w-6 rounded-full bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm flex items-center justify-center transition-colors"
>
{formData.isHidden ? <Eye size={13} /> : <EyeOff size={13} />}
{formData.isHidden ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
</button>
<ShareAssetsTrigger
filters={{ personIds: [person.id] }}
buttonProps={{
variant: "ghost",
className: "!p-1 !h-auto !w-auto rounded-md bg-white/10 hover:bg-white/25 text-white hover:text-white [&>svg]:h-[13px] [&>svg]:w-[13px]",
}}
/>
<PersonMergeDropdown
person={person}
onRemove={onRemove}
triggerClassName="!p-1 !h-auto !w-auto rounded-md !bg-white/10 hover:!bg-white/25 !text-white !border-transparent text-[10px] leading-none"
/>
</div>
</Tooltip>
<Tooltip content="Merge">
<span>
<PersonMergeDropdown
person={person}
onRemove={onRemove}
triggerClassName="!p-0 !h-6 !w-6 rounded-full !bg-black/50 hover:!bg-black/70 !text-white !border-transparent backdrop-blur-sm flex items-center justify-center"
triggerIcon={<GitMerge className="h-3 w-3" />}
/>
</span>
</Tooltip>
</div>
</div>
{/* Name + birthday section */}
<div className="px-2 pt-2 pb-2 flex flex-col gap-1.5">
<div className="px-1.5 py-1.5 flex flex-col gap-1 min-w-0">
{!editMode ? (
<button
className="text-left w-full text-sm font-semibold leading-snug hover:text-muted-foreground transition-colors truncate"
className="text-left w-full text-xs font-semibold leading-snug hover:text-muted-foreground transition-colors truncate"
onClick={() => setEditMode(true)}
title={formData.name || "Click to set name"}
>
{formData.name || (
<span className="text-muted-foreground/50 font-normal italic text-xs">Unknown</span>
<span className="text-muted-foreground/50 font-normal italic text-[11px]">Unknown</span>
)}
</button>
) : (
@@ -161,7 +177,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
)
}
type="text"
className="text-sm font-semibold w-full px-1.5 py-0.5 rounded-md"
className="text-xs font-semibold w-full px-1 py-0.5 rounded-md"
defaultValue={formData.name}
placeholder="Enter name"
autoFocus
@@ -41,6 +41,7 @@ interface PersonMergeDropdownProps {
onRemove?: (person: IPerson) => void;
onComplete?: (mergedPerson: IPerson) => void;
triggerClassName?: string;
triggerIcon?: React.ReactNode;
}
export function PersonMergeDropdown({
@@ -48,6 +49,7 @@ export function PersonMergeDropdown({
onRemove,
onComplete,
triggerClassName,
triggerIcon,
}: PersonMergeDropdownProps) {
const [searchedPeople, setSearchedPeople] = useState<IPerson[] | null>(null);
const [similarPeople, setSimilarPeople] = useState<IPerson[]>([]);
@@ -243,7 +245,7 @@ export function PersonMergeDropdown({
variant="outline"
className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)}
>
Merge
{triggerIcon ?? "Merge"}
</Button>
</DialogTrigger>
<DialogContent>
+11 -2
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogTrigger, DialogDescription } from '../ui/dialog';
import { Button, ButtonProps } from '../ui/button';
import { DropdownMenuItem } from '../ui/dropdown-menu';
import { ShareLinkFilters } from '@/types/shareLink';
import { generateShareLink } from '@/handlers/api/shareLink.handler';
import { createShareKey, getShareKey } from '@/handlers/api/share-key.handler';
@@ -13,9 +14,11 @@ import { Select, SelectValue, SelectContent, SelectItem, SelectTrigger } from '.
interface ShareAssetsTriggerProps {
filters: ShareLinkFilters
buttonProps?: ButtonProps
asDropdownItem?: boolean
children?: React.ReactNode
}
export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssetsTriggerProps) {
export default function ShareAssetsTrigger({ filters, buttonProps, asDropdownItem, children }: ShareAssetsTriggerProps) {
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -91,7 +94,13 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
return (
<Dialog onOpenChange={(open) => { if (open) checkShareKey(); }}>
<DialogTrigger asChild>
<Button {...buttonProps}>Share</Button>
{asDropdownItem ? (
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="gap-2">
{children}
</DropdownMenuItem>
) : (
<Button {...buttonProps}>{children ?? "Share"}</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>