mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 11:51:53 +03:00
use grid for source and target
This commit is contained in:
parent
f8172d6ff7
commit
2aceeb4a1c
@ -51,7 +51,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { LuChevronDown, LuTriangleAlert } from "react-icons/lu";
|
import { LuArrowRight, LuChevronDown, LuTriangleAlert } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
CLONE_CATEGORIES,
|
CLONE_CATEGORIES,
|
||||||
type CloneCategoryKey,
|
type CloneCategoryKey,
|
||||||
@ -578,255 +578,270 @@ export default function CloneCameraDialog({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="relative flex flex-col gap-6 md:grid md:grid-cols-2 md:items-start md:gap-12">
|
||||||
<Label className="text-base">
|
<div className="space-y-3">
|
||||||
{t("cameraManagement.clone.source.label")}
|
<Label className="text-base">
|
||||||
</Label>
|
{t("cameraManagement.clone.source.label")}
|
||||||
<FormField
|
</Label>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="sourceCamera"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="sourceCamera"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Select
|
<FormControl>
|
||||||
value={field.value}
|
<Select
|
||||||
onValueChange={field.onChange}
|
value={field.value}
|
||||||
disabled={isSubmitting}
|
onValueChange={field.onChange}
|
||||||
>
|
disabled={isSubmitting}
|
||||||
<SelectTrigger className="w-full max-w-xs">
|
>
|
||||||
<SelectValue
|
<SelectTrigger className="w-full max-w-xs md:max-w-none">
|
||||||
placeholder={t(
|
<SelectValue
|
||||||
"cameraManagement.clone.source.placeholder",
|
placeholder={t(
|
||||||
)}
|
"cameraManagement.clone.source.placeholder",
|
||||||
/>
|
)}
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{sourceCameras.map((cam) => (
|
|
||||||
<SelectItem key={cam} value={cam}>
|
|
||||||
{config?.cameras?.[cam]?.friendly_name ?? cam}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-base">
|
|
||||||
{t("cameraManagement.clone.target.legend")}
|
|
||||||
</Label>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetMode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
className="space-y-3"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-3 space-y-0">
|
|
||||||
<RadioGroupItem
|
|
||||||
value="new"
|
|
||||||
id="clone-target-new"
|
|
||||||
className={
|
|
||||||
targetMode === "new"
|
|
||||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
||||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label
|
</SelectTrigger>
|
||||||
htmlFor="clone-target-new"
|
<SelectContent>
|
||||||
className="font-normal"
|
{sourceCameras.map((cam) => (
|
||||||
>
|
<SelectItem key={cam} value={cam}>
|
||||||
{t("cameraManagement.clone.target.newRadio")}
|
{config?.cameras?.[cam]?.friendly_name ?? cam}
|
||||||
</Label>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
{targetMode === "new" && (
|
</SelectContent>
|
||||||
<FormItem className="ml-7">
|
</Select>
|
||||||
<FormLabel className="sr-only">
|
</FormControl>
|
||||||
{t(
|
<FormMessage />
|
||||||
"cameraManagement.clone.target.newNameLabel",
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
/>
|
||||||
<FormControl>
|
</div>
|
||||||
<Input
|
|
||||||
{...form.register("newName")}
|
<div className="space-y-3">
|
||||||
className="max-w-xs"
|
<Label className="text-base">
|
||||||
placeholder={t(
|
{t("cameraManagement.clone.target.legend")}
|
||||||
"cameraManagement.clone.target.newNamePlaceholder",
|
</Label>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="targetMode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-3 space-y-0">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="new"
|
||||||
|
id="clone-target-new"
|
||||||
|
className={
|
||||||
|
targetMode === "new"
|
||||||
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="clone-target-new"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
{t("cameraManagement.clone.target.newRadio")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{targetMode === "new" && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.target.newNameLabel",
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
</FormLabel>
|
||||||
autoFocus
|
<FormControl>
|
||||||
/>
|
<Input
|
||||||
</FormControl>
|
{...form.register("newName")}
|
||||||
{form.formState.errors.newName?.message && (
|
className="max-w-xs md:max-w-none"
|
||||||
<p className="text-sm font-medium text-destructive">
|
placeholder={t(
|
||||||
{String(
|
"cameraManagement.clone.target.newNamePlaceholder",
|
||||||
form.formState.errors.newName.message,
|
)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{form.formState.errors.newName?.message && (
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{String(
|
||||||
|
form.formState.errors.newName.message,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"cameraManagement.clone.target.newStreamsForced",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</FormItem>
|
||||||
<p className="text-xs text-muted-foreground">
|
)}
|
||||||
{t(
|
|
||||||
"cameraManagement.clone.target.newStreamsForced",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-3 space-y-0">
|
|
||||||
<RadioGroupItem
|
|
||||||
value="existing"
|
|
||||||
id="clone-target-existing"
|
|
||||||
disabled={otherCameras.length === 0}
|
|
||||||
className={
|
|
||||||
targetMode === "existing"
|
|
||||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
||||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="clone-target-existing"
|
|
||||||
className={cn(
|
|
||||||
"font-normal",
|
|
||||||
otherCameras.length === 0 &&
|
|
||||||
"text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"cameraManagement.clone.target.existingCamerasRadio",
|
|
||||||
)}
|
|
||||||
{otherCameras.length === 0 && (
|
|
||||||
<span className="ml-2 text-xs">
|
|
||||||
(
|
|
||||||
{t(
|
|
||||||
"cameraManagement.clone.target.existingDisabled",
|
|
||||||
)}
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
{targetMode === "existing" &&
|
<div className="space-y-2">
|
||||||
otherCameras.length > 0 && (
|
<div className="flex items-center space-x-3 space-y-0">
|
||||||
<FormField
|
<RadioGroupItem
|
||||||
control={form.control}
|
value="existing"
|
||||||
name="existingTargets"
|
id="clone-target-existing"
|
||||||
render={({ field: tgtField }) => {
|
disabled={otherCameras.length === 0}
|
||||||
const selected = tgtField.value ?? [];
|
className={
|
||||||
const allSelected =
|
targetMode === "existing"
|
||||||
otherCameras.length > 0 &&
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
otherCameras.every((c) =>
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
selected.includes(c),
|
}
|
||||||
);
|
/>
|
||||||
const selectedNames = otherCameras
|
<Label
|
||||||
.filter((c) => selected.includes(c))
|
htmlFor="clone-target-existing"
|
||||||
.map(
|
className={cn(
|
||||||
(c) =>
|
"font-normal",
|
||||||
config?.cameras?.[c]?.friendly_name ??
|
otherCameras.length === 0 &&
|
||||||
c,
|
"text-muted-foreground",
|
||||||
);
|
)}
|
||||||
const summary = allSelected
|
>
|
||||||
? t(
|
{t(
|
||||||
"cameraManagement.clone.target.allCameras",
|
"cameraManagement.clone.target.existingCamerasRadio",
|
||||||
)
|
)}
|
||||||
: selectedNames.length > 0
|
{otherCameras.length === 0 && (
|
||||||
? selectedNames.join(", ")
|
<span className="ml-2 text-xs">
|
||||||
: t(
|
(
|
||||||
"cameraManagement.clone.target.existingPlaceholder",
|
{t(
|
||||||
);
|
"cameraManagement.clone.target.existingDisabled",
|
||||||
return (
|
)}
|
||||||
<FormItem className="ml-7">
|
)
|
||||||
<Popover>
|
</span>
|
||||||
<FormControl>
|
)}
|
||||||
<PopoverTrigger asChild>
|
</Label>
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
{targetMode === "existing" &&
|
||||||
variant="outline"
|
otherCameras.length > 0 && (
|
||||||
disabled={isSubmitting}
|
<FormField
|
||||||
className="w-full max-w-xs justify-between font-normal"
|
control={form.control}
|
||||||
>
|
name="existingTargets"
|
||||||
<span
|
render={({ field: tgtField }) => {
|
||||||
className={cn(
|
const selected = tgtField.value ?? [];
|
||||||
"truncate",
|
const allSelected =
|
||||||
selectedNames.length === 0 &&
|
otherCameras.length > 0 &&
|
||||||
"text-muted-foreground",
|
otherCameras.every((c) =>
|
||||||
)}
|
selected.includes(c),
|
||||||
|
);
|
||||||
|
const selectedNames = otherCameras
|
||||||
|
.filter((c) => selected.includes(c))
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
config?.cameras?.[c]?.friendly_name ??
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
const summary = allSelected
|
||||||
|
? t(
|
||||||
|
"cameraManagement.clone.target.allCameras",
|
||||||
|
)
|
||||||
|
: selectedNames.length > 0
|
||||||
|
? selectedNames.join(", ")
|
||||||
|
: t(
|
||||||
|
"cameraManagement.clone.target.existingPlaceholder",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<Popover>
|
||||||
|
<FormControl>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full max-w-xs justify-between font-normal md:max-w-none"
|
||||||
>
|
>
|
||||||
{summary}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
<LuChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
"truncate",
|
||||||
</Button>
|
selectedNames.length ===
|
||||||
</PopoverTrigger>
|
0 &&
|
||||||
</FormControl>
|
"text-muted-foreground",
|
||||||
<PopoverContent
|
)}
|
||||||
align="start"
|
>
|
||||||
disablePortal
|
{summary}
|
||||||
className="w-[--radix-popover-trigger-width] p-0"
|
</span>
|
||||||
>
|
<LuChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
<div className="scrollbar-container max-h-60 space-y-2.5 overflow-y-auto p-4">
|
</Button>
|
||||||
<FilterSwitch
|
</PopoverTrigger>
|
||||||
label={t(
|
</FormControl>
|
||||||
"cameraManagement.clone.target.allCameras",
|
<PopoverContent
|
||||||
)}
|
align="start"
|
||||||
isChecked={allSelected}
|
disablePortal
|
||||||
disabled={isSubmitting}
|
className="w-[--radix-popover-trigger-width] p-0"
|
||||||
onCheckedChange={(checked) =>
|
>
|
||||||
tgtField.onChange(
|
<div className="scrollbar-container max-h-60 space-y-4 overflow-y-auto p-4">
|
||||||
checked
|
|
||||||
? [...otherCameras]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="border-t border-border/40" />
|
|
||||||
{otherCameras.map((cam) => (
|
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={cam}
|
label={t(
|
||||||
label={cam}
|
"cameraManagement.clone.target.allCameras",
|
||||||
type="camera"
|
|
||||||
isChecked={selected.includes(
|
|
||||||
cam,
|
|
||||||
)}
|
)}
|
||||||
|
isChecked={allSelected}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
tgtField.onChange(
|
tgtField.onChange(
|
||||||
checked
|
checked
|
||||||
? [...selected, cam]
|
? [...otherCameras]
|
||||||
: selected.filter(
|
: [],
|
||||||
(c) => c !== cam,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
<div className="space-y-2.5">
|
||||||
</div>
|
{otherCameras.map((cam) => (
|
||||||
</PopoverContent>
|
<FilterSwitch
|
||||||
</Popover>
|
key={cam}
|
||||||
<FormMessage />
|
label={cam}
|
||||||
</FormItem>
|
type="camera"
|
||||||
);
|
isChecked={selected.includes(
|
||||||
}}
|
cam,
|
||||||
/>
|
)}
|
||||||
)}
|
disabled={isSubmitting}
|
||||||
</div>
|
onCheckedChange={(
|
||||||
</RadioGroup>
|
checked,
|
||||||
</FormControl>
|
) =>
|
||||||
</FormItem>
|
tgtField.onChange(
|
||||||
)}
|
checked
|
||||||
/>
|
? [...selected, cam]
|
||||||
|
: selected.filter(
|
||||||
|
(c) => c !== cam,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-0 hidden -translate-x-1/2 flex-col space-y-3 md:flex">
|
||||||
|
<Label className="invisible text-base" aria-hidden="true">
|
||||||
|
{" "}
|
||||||
|
</Label>
|
||||||
|
<div className="flex h-10 items-center justify-center">
|
||||||
|
<LuArrowRight className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-row items-start justify-between gap-2">
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-base">
|
<Label className="text-base">
|
||||||
{t("cameraManagement.clone.categories.legend")}
|
{t("cameraManagement.clone.categories.legend")}
|
||||||
@ -835,16 +850,16 @@ export default function CloneCameraDialog({
|
|||||||
{t("cameraManagement.clone.categories.description")}
|
{t("cameraManagement.clone.categories.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2 text-right text-xs text-muted-foreground">
|
<div className="flex flex-row items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer"
|
className="cursor-pointer whitespace-nowrap"
|
||||||
onClick={isSubmitting ? undefined : selectAllCategories}
|
onClick={isSubmitting ? undefined : selectAllCategories}
|
||||||
>
|
>
|
||||||
{t("cameraManagement.clone.categories.selectAll")}
|
{t("cameraManagement.clone.categories.selectAll")}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true">|</span>
|
<span aria-hidden="true">|</span>
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer"
|
className="cursor-pointer whitespace-nowrap"
|
||||||
onClick={isSubmitting ? undefined : selectNoneCategories}
|
onClick={isSubmitting ? undefined : selectNoneCategories}
|
||||||
>
|
>
|
||||||
{t("cameraManagement.clone.categories.selectNone")}
|
{t("cameraManagement.clone.categories.selectNone")}
|
||||||
@ -856,7 +871,7 @@ export default function CloneCameraDialog({
|
|||||||
<Label className="font-medium">
|
<Label className="font-medium">
|
||||||
{t("cameraManagement.clone.categories.general")}
|
{t("cameraManagement.clone.categories.general")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-1 gap-3 rounded-lg bg-secondary p-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-12 gap-y-3 rounded-lg bg-secondary p-4 sm:grid-cols-2">
|
||||||
{groupedCategories.general.map((cat) => (
|
{groupedCategories.general.map((cat) => (
|
||||||
<label
|
<label
|
||||||
key={cat.key}
|
key={cat.key}
|
||||||
@ -911,7 +926,7 @@ export default function CloneCameraDialog({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 gap-3 rounded-lg bg-secondary p-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-12 gap-y-3 rounded-lg bg-secondary p-4 sm:grid-cols-2">
|
||||||
{groupedCategories.spatial.map((cat) => (
|
{groupedCategories.spatial.map((cat) => (
|
||||||
<label
|
<label
|
||||||
key={cat.key}
|
key={cat.key}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user