Files
immich-power-tools/src/components/workflows/config/ConditionEditor.tsx
T

410 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { ICondition, ConditionType } from "@/types/workflow";
import { Plus, X, Check } from "lucide-react";
import { listPeople } from "@/handlers/api/people.handler";
import { IPerson } from "@/types/person";
import { PERSON_THUBNAIL_PATH } from "@/config/routes";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const conditionTypeLabels: Record<ConditionType, string> = {
person: "Person",
person_unnamed: "Unnamed People",
city: "City",
state: "State",
country: "Country",
geo_radius: "Geo Radius",
date_range: "Date Range",
date_relative: "Relative Date",
day_of_week: "Day of Week",
camera_make: "Camera Make",
camera_model: "Camera Model",
lens: "Lens",
asset_type: "Asset Type",
iso_range: "ISO Range",
focal_length: "Focal Length",
resolution: "Resolution",
rating: "Rating",
is_favorited: "Favorited",
file_size: "File Size",
filename: "File Name / Path",
file_extension: "File Extension",
face_count: "Face Count",
time_of_day: "Time of Day",
not_in_album: "Not in Any Album",
not_in_specific_album: "Not in Specific Album",
};
const conditionTypes = Object.keys(conditionTypeLabels) as ConditionType[];
interface PersonPickerProps {
selectedIds: string[];
onChange: (personIds: string[], personNames: string[]) => void;
}
function PersonPicker({ selectedIds, onChange }: PersonPickerProps) {
const [open, setOpen] = useState(false);
const [people, setPeople] = useState<IPerson[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
listPeople({ page: 1, perPage: 500, sort: "createdAt", sortOrder: "desc", visibility: "visible", type: "named" })
.then((res) => setPeople(res.people))
.finally(() => setLoading(false));
}, []);
const selectedSet = new Set(selectedIds || []);
const selectedPeople = people.filter((p) => selectedSet.has(p.id));
const togglePerson = (person: IPerson) => {
let nextIds: string[];
let nextNames: string[];
if (selectedSet.has(person.id)) {
nextIds = selectedIds.filter((id) => id !== person.id);
nextNames = selectedPeople.filter((p) => p.id !== person.id).map((p) => p.name);
} else {
nextIds = [...selectedIds, person.id];
nextNames = [...selectedPeople.map((p) => p.name), person.name];
}
onChange(nextIds, nextNames);
};
return (
<div className="space-y-2">
{/* Selected people chips */}
{selectedPeople.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedPeople.map((p) => (
<button
key={p.id}
type="button"
onClick={() => togglePerson(p)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-muted hover:bg-destructive/10 transition-colors group"
>
<img src={PERSON_THUBNAIL_PATH(p.id)} alt="" className="h-4 w-4 rounded-full object-cover" />
<span className="text-[10px] font-medium">{p.name || "Unknown"}</span>
<X className="h-2.5 w-2.5 text-muted-foreground group-hover:text-destructive" />
</button>
))}
</div>
)}
{/* Picker */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className="flex items-center gap-2 h-7 px-2 w-full border rounded text-xs bg-background hover:bg-muted transition-colors">
<span className="text-muted-foreground">
{selectedIds.length === 0 ? "Select people..." : "Add more..."}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0 z-[10000]" align="start">
<Command>
<CommandInput placeholder="Search people..." className="text-xs" />
<CommandList>
<CommandEmpty>{loading ? "Loading..." : "No people found."}</CommandEmpty>
<CommandGroup>
{people.map((person) => (
<CommandItem
key={person.id}
value={person.name || person.id}
onSelect={() => togglePerson(person)}
className="flex items-center gap-2"
>
<img src={PERSON_THUBNAIL_PATH(person.id)} alt="" className="h-6 w-6 rounded-full object-cover" />
<span className="text-xs truncate flex-1">{person.name || "Unknown"}</span>
<Check className={cn("h-3 w-3", selectedSet.has(person.id) ? "opacity-100" : "opacity-0")} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
interface ConditionEditorProps {
conditions: ICondition[];
onChange: (conditions: ICondition[]) => void;
}
function ConditionFields({ condition, onChange }: { condition: ICondition; onChange: (c: ICondition) => void }) {
switch (condition.type) {
case "city":
case "state":
case "country":
return (
<div className="flex gap-2">
<Select value={condition.match || "equals"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs w-24"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="not_equals">Not Equals</SelectItem>
</SelectContent>
</Select>
<Input className="h-7 text-xs" placeholder={condition.type} value={condition[condition.type] || ""} onChange={(e) => onChange({ ...condition, [condition.type]: e.target.value })} />
</div>
);
case "camera_make":
return <Input className="h-7 text-xs" placeholder="e.g. Apple" value={condition.make || ""} onChange={(e) => onChange({ ...condition, make: e.target.value })} />;
case "camera_model":
return <Input className="h-7 text-xs" placeholder="e.g. iPhone 16 Pro" value={condition.model || ""} onChange={(e) => onChange({ ...condition, model: e.target.value })} />;
case "lens":
return <Input className="h-7 text-xs" placeholder="Lens model" value={condition.lensModel || ""} onChange={(e) => onChange({ ...condition, lensModel: e.target.value })} />;
case "asset_type":
return (
<Select value={condition.assetType || "IMAGE"} onValueChange={(v) => onChange({ ...condition, assetType: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="IMAGE">Image</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
</SelectContent>
</Select>
);
case "date_range":
return (
<div className="flex gap-2">
<Input className="h-7 text-xs" type="date" value={condition.after || ""} onChange={(e) => onChange({ ...condition, after: e.target.value })} />
<Input className="h-7 text-xs" type="date" value={condition.before || ""} onChange={(e) => onChange({ ...condition, before: e.target.value })} />
</div>
);
case "date_relative":
return (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Last</span>
<Input className="h-7 text-xs w-16" type="number" min={1} value={condition.lastDays || ""} onChange={(e) => onChange({ ...condition, lastDays: parseInt(e.target.value) || 0 })} />
<span className="text-xs text-muted-foreground">days</span>
</div>
);
case "iso_range":
case "focal_length":
return (
<div className="flex gap-2">
<Input className="h-7 text-xs" type="number" placeholder="Min" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: parseFloat(e.target.value) || undefined })} />
<Input className="h-7 text-xs" type="number" placeholder="Max" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: parseFloat(e.target.value) || undefined })} />
</div>
);
case "rating":
return (
<div className="flex gap-2">
<Input className="h-7 text-xs w-16" type="number" min={1} max={5} placeholder="Min" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: parseInt(e.target.value) || undefined })} />
<Input className="h-7 text-xs w-16" type="number" min={1} max={5} placeholder="Max" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: parseInt(e.target.value) || undefined })} />
</div>
);
case "resolution": {
const unit = (condition.metric || "megapixels") === "megapixels" ? "MP" : "px";
return (
<div className="space-y-2">
<Select value={condition.metric || "megapixels"} onValueChange={(v) => onChange({ ...condition, metric: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="megapixels">Megapixels</SelectItem>
<SelectItem value="short_edge">Short Edge (px)</SelectItem>
<SelectItem value="long_edge">Long Edge (px)</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder={`Min ${unit}`} value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder={`Max ${unit}`} value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
</div>
</div>
);
}
case "is_favorited":
return (
<Select value={condition.value === false ? "false" : "true"} onValueChange={(v) => onChange({ ...condition, value: v === "true" })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="true">Is Favorited</SelectItem>
<SelectItem value="false">Not Favorited</SelectItem>
</SelectContent>
</Select>
);
case "file_size":
return (
<div className="flex items-center gap-2">
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder="Min MB" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder="Max MB" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
</div>
);
case "filename":
return (
<div className="space-y-2">
<div className="flex gap-2">
<Select value={condition.field || "name"} onValueChange={(v) => onChange({ ...condition, field: v })}>
<SelectTrigger className="h-7 text-xs w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="name">File Name</SelectItem>
<SelectItem value="path">File Path</SelectItem>
</SelectContent>
</Select>
<Select value={condition.match || "contains"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="not_contains">Does not contain</SelectItem>
<SelectItem value="starts_with">Starts with</SelectItem>
<SelectItem value="ends_with">Ends with</SelectItem>
</SelectContent>
</Select>
</div>
<Input className="h-7 text-xs" placeholder="e.g. Screenshot" value={condition.text || ""} onChange={(e) => onChange({ ...condition, text: e.target.value })} />
</div>
);
case "file_extension":
return (
<div className="space-y-2">
<Select value={condition.match || "in"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="in">Is any of</SelectItem>
<SelectItem value="not_in">Is none of</SelectItem>
</SelectContent>
</Select>
<Input className="h-7 text-xs" placeholder="e.g. png, gif, heic" value={condition.extensions || ""} onChange={(e) => onChange({ ...condition, extensions: e.target.value })} />
</div>
);
case "face_count":
return (
<div className="flex items-center gap-2">
<Input className="h-7 text-xs w-20" type="number" min={0} placeholder="Min" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<Input className="h-7 text-xs w-20" type="number" min={0} placeholder="Max" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">faces</span>
</div>
);
case "time_of_day":
return (
<div className="space-y-1">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">From</span>
<Input className="h-7 text-xs w-16" type="number" min={0} max={23} placeholder="0" value={condition.fromHour ?? ""} onChange={(e) => onChange({ ...condition, fromHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">to</span>
<Input className="h-7 text-xs w-16" type="number" min={0} max={23} placeholder="23" value={condition.toHour ?? ""} onChange={(e) => onChange({ ...condition, toHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">h</span>
</div>
<p className="text-[10px] text-muted-foreground">Hours 0–23; From &gt; To wraps past midnight (e.g. 22 to 5).</p>
</div>
);
case "person":
return (
<div className="space-y-2">
<Select value={condition.match || "contains_any"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="contains_any">Contains any of</SelectItem>
<SelectItem value="contains_all">Contains all of</SelectItem>
<SelectItem value="not_contains">Does not contain</SelectItem>
</SelectContent>
</Select>
<PersonPicker
selectedIds={condition.personIds || (condition.personId ? [condition.personId] : [])}
onChange={(personIds, personNames) => onChange({ ...condition, personIds, personNames })}
/>
</div>
);
case "geo_radius":
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input className="h-7 text-xs" type="number" step="any" placeholder="Latitude" value={condition.lat ?? ""} onChange={(e) => onChange({ ...condition, lat: parseFloat(e.target.value) || 0 })} />
<Input className="h-7 text-xs" type="number" step="any" placeholder="Longitude" value={condition.lng ?? ""} onChange={(e) => onChange({ ...condition, lng: parseFloat(e.target.value) || 0 })} />
</div>
<div className="flex items-center gap-1">
<Input className="h-7 text-xs w-20" type="number" min={1} placeholder="Radius" value={condition.radiusKm ?? ""} onChange={(e) => onChange({ ...condition, radiusKm: parseFloat(e.target.value) || 0 })} />
<span className="text-xs text-muted-foreground">km</span>
</div>
</div>
);
case "day_of_week":
return (
<div className="flex gap-1 flex-wrap">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day, i) => {
const days: number[] = condition.days || [];
const active = days.includes(i);
return (
<button
key={day}
type="button"
className={`text-[10px] px-1.5 py-0.5 rounded border ${active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
onClick={() => {
const next = active ? days.filter((d: number) => d !== i) : [...days, i];
onChange({ ...condition, days: next });
}}
>
{day}
</button>
);
})}
</div>
);
case "not_in_album":
case "person_unnamed":
return null;
case "not_in_specific_album":
return <Input className="h-7 text-xs" placeholder="Album ID" value={condition.albumId || ""} onChange={(e) => onChange({ ...condition, albumId: e.target.value })} />;
default:
return null;
}
}
export default function ConditionEditor({ conditions, onChange }: ConditionEditorProps) {
const addCondition = () => {
onChange([...conditions, { type: "city" }]);
};
const removeCondition = (index: number) => {
onChange(conditions.filter((_, i) => i !== index));
};
const updateCondition = (index: number, condition: ICondition) => {
const next = [...conditions];
next[index] = condition;
onChange(next);
};
const changeType = (index: number, type: ConditionType) => {
const next = [...conditions];
next[index] = { type };
onChange(next);
};
return (
<div className="space-y-2">
{conditions.map((condition, i) => (
<div key={i} className="p-2 border rounded space-y-2 bg-muted/30">
<div className="flex items-center gap-1">
<Select value={condition.type} onValueChange={(v) => changeType(i, v as ConditionType)}>
<SelectTrigger className="h-7 text-xs flex-1"><SelectValue /></SelectTrigger>
<SelectContent>
{conditionTypes.map((t) => (
<SelectItem key={t} value={t}>{conditionTypeLabels[t]}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => removeCondition(i)}>
<X className="h-3 w-3" />
</Button>
</div>
<ConditionFields condition={condition} onChange={(c) => updateCondition(i, c)} />
{i < conditions.length - 1 && (
<div className="text-center">
<span className="text-[10px] font-medium text-muted-foreground bg-muted px-2 py-0.5 rounded">AND</span>
</div>
)}
</div>
))}
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={addCondition}>
<Plus className="h-3 w-3 mr-1" />
Add Condition
</Button>
</div>
);
}