feat: people card redesign - quick hide button, dropdown menu, grey border
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user