Compare commits

..

5 Commits

Author SHA1 Message Date
GuoQing Liu
dbb93ddd17
Merge 46a5eb4647 into 088e1ad7ef 2026-04-29 06:43:04 +00:00
ZhaiSoul
46a5eb4647 docs: add docker compose tabs 2026-04-29 14:42:48 +08:00
ZhaiSoul
4b421c66a5 docs: improve NVIDIA GPU count input 2026-04-29 14:42:29 +08:00
ZhaiSoul
6cc4db1103 docs: remove 5000 port tips 2026-04-29 14:13:32 +08:00
Nicolas Mowen
088e1ad7ef
Add ability to download case as zip (#23034)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-04-28 19:11:41 -05:00
11 changed files with 278 additions and 225 deletions

View File

@ -5,7 +5,8 @@ title: Installation
import ShmCalculator from '@site/src/components/ShmCalculator' import ShmCalculator from '@site/src/components/ShmCalculator'
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator' import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App. Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
@ -73,13 +74,6 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
::: :::
### Docker Compose Generator
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
<DockerComposeGenerator/>
### Calculating required shm-size ### Calculating required shm-size
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**. Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
@ -477,6 +471,16 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
Running through Docker with Docker Compose is the recommended install method. Running through Docker with Docker Compose is the recommended install method.
<Tabs>
<TabItem value="domestic" label="Docker Compose Generator" default>
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
<DockerComposeGenerator/>
</TabItem>
<TabItem value="original" label="Example Docker Compose File">
```yaml ```yaml
services: services:
frigate: frigate:
@ -510,6 +514,10 @@ services:
environment: environment:
FRIGATE_RTSP_PASSWORD: "password" FRIGATE_RTSP_PASSWORD: "password"
``` ```
</TabItem>
</Tabs>
**Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this: If you can't use Docker Compose, you can run the container with something similar to this:

View File

@ -32,12 +32,12 @@ function renderHelpText(text: string): React.ReactNode {
export default function DockerComposeGenerator() { export default function DockerComposeGenerator() {
const { const {
deviceId, device, hardwareEnabled, deviceId, device, hardwareEnabled,
portEnabled, port5000Confirmed, portEnabled,
nvidiaGpuCount, nvidiaGpuDeviceId, nvidiaGpuCount, nvidiaGpuDeviceId,
configPath, mediaPath, rtspPassword, timezone, shmSize, configPath, mediaPath, rtspPassword, timezone, shmSize,
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
hasAnyHardware, generatedYaml, hasAnyHardware, generatedYaml,
selectDevice, toggleHardware, togglePort, setPort5000Confirmed, selectDevice, toggleHardware, togglePort,
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
setRtspPassword, setTimezone, isHardwareDisabled, setRtspPassword, setTimezone, isHardwareDisabled,
@ -82,9 +82,7 @@ export default function DockerComposeGenerator() {
<PortConfigSection <PortConfigSection
portEnabled={portEnabled} portEnabled={portEnabled}
port5000Confirmed={port5000Confirmed}
onTogglePort={togglePort} onTogglePort={togglePort}
onConfirm5000={setPort5000Confirmed}
/> />
<OtherOptions <OtherOptions

View File

@ -16,6 +16,8 @@ export default function NvidiaGpuConfig({
onGpuCountChange, onGpuCountChange,
onGpuDeviceIdChange, onGpuDeviceIdChange,
}: Props) { }: Props) {
const showDeviceId = gpuCount !== "";
return ( return (
<div className={styles.nvidiaConfig}> <div className={styles.nvidiaConfig}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -25,16 +27,15 @@ export default function NvidiaGpuConfig({
<input <input
id="dcg-gpu-count" id="dcg-gpu-count"
type="text" type="text"
inputMode="numeric"
pattern="[0-9]*"
className={styles.input} className={styles.input}
value={gpuCount} value={gpuCount}
placeholder="1 or all" placeholder="all"
onChange={(e) => onGpuCountChange(e.target.value)} onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
/> />
<p className={styles.helpText}>
Enter a number (e.g. 1, 2, 3) or &quot;all&quot; to use all GPUs
</p>
</div> </div>
{gpuCount !== "all" && ( {showDeviceId && (
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label htmlFor="dcg-gpu-device-id" className={styles.label}> <label htmlFor="dcg-gpu-device-id" className={styles.label}>
GPU device IDs (required, comma-separated): GPU device IDs (required, comma-separated):

View File

@ -1,125 +1,71 @@
import React from "react"; import React from "react";
import Admonition from "@theme/Admonition"; import Admonition from "@theme/Admonition";
import { ports } from "../config"; import { ports } from "../config";
import { useCooldown } from "../hooks/useCooldown";
import styles from "../styles.module.css"; import styles from "../styles.module.css";
interface Props { interface Props {
portEnabled: Record<string, boolean>; portEnabled: Record<string, boolean>;
port5000Confirmed: boolean;
onTogglePort: (portId: string) => void; onTogglePort: (portId: string) => void;
onConfirm5000: (confirmed: boolean) => void;
} }
function Port5000Confirmation({ function PortItem({
portEnabled, port,
confirmed, enabled,
onToggle, onToggle,
onConfirm,
}: { }: {
portEnabled: boolean; port: typeof ports[number];
confirmed: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;
onConfirm: (confirmed: boolean) => void;
}) { }) {
const { remaining, start, stop } = useCooldown(10); const showWarning = port.warningContent && (
port.warningWhen === "checked" ? enabled :
React.useEffect(() => { port.warningWhen === "unchecked" ? !enabled : enabled
if (portEnabled) { );
start();
} else {
stop();
onConfirm(false);
}
return stop;
}, [portEnabled]);
return ( return (
<div className={styles.portSection}> <div className={styles.hardwareItem}>
{portEnabled && ( <label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
<Admonition type="danger">
<p>
Exposing port 5000 allows <strong>unauthenticated access</strong> to
your Frigate instance. Anyone on your network (or the internet if you
have a public IP) could access it without credentials.
</p>
<p>
This may lead to <strong>unauthorized access</strong>,{" "}
<strong>privacy leaks</strong>, or further attacks. Ensure you have
proper firewall rules or VPN in place.
</p>
<label
className={`${styles.checkboxLabel} ${remaining > 0 ? styles.checkboxDisabled : ""}`}
>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => onConfirm(e.target.checked)}
disabled={remaining > 0}
/>
<span>
I understand the risk and confirm enabling port 5000
{remaining > 0 && ` (${remaining}s)`}
</span>
</label>
</Admonition>
)}
<label className={styles.checkboxLabel}>
<input <input
type="checkbox" type="checkbox"
checked={portEnabled} checked={enabled}
onChange={onToggle} onChange={onToggle}
disabled={port.locked}
/> />
<span>Port 5000 (unauthenticated access)</span> <span>
<span className={styles.warningBadge}> Expose carefully</span> {port.locked && "🔒 "}
Port {port.host}
{port.protocol !== "tcp" && `/${port.protocol}`}
</span>
</label> </label>
{port.description && (
<div className={styles.hardwareDescription}>{port.description}</div>
)}
{showWarning && (
<Admonition type={port.warningType || "warning"}>
{port.warningContent}
</Admonition>
)}
</div> </div>
); );
} }
export default function PortConfigSection({ export default function PortConfigSection({
portEnabled, portEnabled,
port5000Confirmed,
onTogglePort, onTogglePort,
onConfirm5000,
}: Props) { }: Props) {
return ( return (
<div className={styles.formSection}> <div className={styles.formSection}>
<h4>Port Configuration</h4> <h4>Port Configuration</h4>
{/* All ports except 5000 */}
<div className={styles.checkboxGrid}> <div className={styles.checkboxGrid}>
{ports {ports.map((port) => (
.filter((p) => p.id !== "5000") <PortItem
.map((port) => ( key={port.id}
<div key={port.id} className={styles.hardwareItem}> port={port}
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}> enabled={!!portEnabled[port.id]}
<input onToggle={() => onTogglePort(port.id)}
type="checkbox" />
checked={!!portEnabled[port.id]} ))}
onChange={() => onTogglePort(port.id)}
disabled={port.locked}
/>
<span>
{port.locked && "🔒 "}
Port {port.host}
{port.protocol !== "tcp" && `/${port.protocol}`}
</span>
</label>
{port.description && (
<div className={styles.hardwareDescription}>{port.description}</div>
)}
</div>
))}
</div> </div>
{/* Port 5000 with special warning — placed last */}
<Port5000Confirmation
portEnabled={!!portEnabled["5000"]}
confirmed={port5000Confirmed}
onToggle={() => onTogglePort("5000")}
onConfirm={onConfirm5000}
/>
</div> </div>
); );
} }

View File

@ -265,7 +265,8 @@ ports:
protocol: "tcp" protocol: "tcp"
description: "Authenticated UI and API access (default HTTPS)" description: "Authenticated UI and API access (default HTTPS)"
defaultEnabled: true defaultEnabled: true
locked: true warningContent: "This is the access port for Frigate. Closing it means you will no longer be able to access the instance."
warningWhen: "unchecked"
- id: "8554" - id: "8554"
host: 8554 host: 8554

View File

@ -145,14 +145,10 @@ export interface PortConfig {
defaultEnabled: boolean; defaultEnabled: boolean;
/** Whether this port is locked (always enabled, cannot be toggled off) */ /** Whether this port is locked (always enabled, cannot be toggled off) */
locked?: boolean; locked?: boolean;
/** Whether this port requires a confirmation step before enabling */
requiresConfirmation?: boolean;
/** Admonition type for the warning */ /** Admonition type for the warning */
warningType?: "warning" | "danger"; warningType?: "warning" | "danger";
/** Warning content (markdown) */ /** Warning content (markdown) */
warningContent?: string; warningContent?: string;
/** Confirmation checkbox label */ /** When to show the warning: when the port is checked or unchecked */
confirmationLabel?: string; warningWhen?: "checked" | "unchecked";
/** Cooldown in seconds before the confirmation checkbox becomes available */
cooldownSeconds?: number;
} }

View File

@ -123,7 +123,7 @@ function buildEnvironment(
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] { function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
if (device.id === "stable-tensorrt") { if (device.id === "stable-tensorrt") {
const count = input.nvidiaGpuCount || "all"; const count = input.nvidiaGpuCount || "all";
const isAll = count.toLowerCase() === "all"; const isAll = count === "all";
const deviceId = input.nvidiaGpuDeviceId?.trim(); const deviceId = input.nvidiaGpuDeviceId?.trim();
if (isAll) { if (isAll) {

View File

@ -29,8 +29,7 @@ export function useConfigGenerator() {
return initial; return initial;
}); });
const [port5000Confirmed, setPort5000Confirmed] = useState(false); const [nvidiaGpuCount, setNvidiaGpuCount] = useState("");
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("all");
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState(""); const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
const [configPath, setConfigPath] = useState(""); const [configPath, setConfigPath] = useState("");
const [mediaPath, setMediaPath] = useState(""); const [mediaPath, setMediaPath] = useState("");
@ -57,7 +56,7 @@ export function useConfigGenerator() {
} }
return next; return next;
}); });
setNvidiaGpuCount("all"); setNvidiaGpuCount("");
setNvidiaGpuDeviceId(""); setNvidiaGpuDeviceId("");
setGpuDeviceIdError(false); setGpuDeviceIdError(false);
}, []); }, []);
@ -69,13 +68,7 @@ export function useConfigGenerator() {
const togglePort = useCallback((portId: string) => { const togglePort = useCallback((portId: string) => {
const port = portMap.get(portId); const port = portMap.get(portId);
if (port?.locked) return; if (port?.locked) return;
setPortEnabled((prev) => { setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] }));
const next = { ...prev, [portId]: !prev[portId] };
if (portId === "5000" && !next[portId]) {
setPort5000Confirmed(false);
}
return next;
});
}, []); }, []);
const isHardwareDisabled = useCallback( const isHardwareDisabled = useCallback(
@ -128,15 +121,13 @@ export function useConfigGenerator() {
); );
const handleNvidiaGpuCountChange = useCallback((value: string) => { const handleNvidiaGpuCountChange = useCallback((value: string) => {
const lower = value.trim().toLowerCase(); // Only allow digits
if (lower === "all" || lower === "" || /^[0-9]+$/.test(lower)) { setNvidiaGpuCount(value);
setNvidiaGpuCount(lower || "all"); if (value === "") {
if (lower === "all") { setNvidiaGpuDeviceId("");
setNvidiaGpuDeviceId(""); setGpuDeviceIdError(false);
setGpuDeviceIdError(false); } else {
} else if (/^[0-9]+$/.test(lower)) { setGpuDeviceIdError(false);
setGpuDeviceIdError(false);
}
} }
}, []); }, []);
@ -149,7 +140,6 @@ export function useConfigGenerator() {
const lines: string[] = []; const lines: string[] = [];
for (const [id, enabled] of Object.entries(portEnabled)) { for (const [id, enabled] of Object.entries(portEnabled)) {
if (!enabled) continue; if (!enabled) continue;
if (id === "5000" && !port5000Confirmed) continue;
const p = portMap.get(id); const p = portMap.get(id);
if (!p) continue; if (!p) continue;
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : ""; const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
@ -157,7 +147,7 @@ export function useConfigGenerator() {
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`); lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
} }
return lines; return lines;
}, [portEnabled, port5000Confirmed]); }, [portEnabled]);
const selectedHardwareIds = useMemo(() => { const selectedHardwareIds = useMemo(() => {
return Object.entries(hardwareEnabled) return Object.entries(hardwareEnabled)
@ -195,11 +185,11 @@ export function useConfigGenerator() {
return { return {
deviceId, device, hardwareEnabled, portEnabled, deviceId, device, hardwareEnabled, portEnabled,
port5000Confirmed, nvidiaGpuCount, nvidiaGpuDeviceId, nvidiaGpuCount, nvidiaGpuDeviceId,
configPath, mediaPath, rtspPassword, timezone, shmSize, configPath, mediaPath, rtspPassword, timezone, shmSize,
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
hasAnyHardware, generatedYaml, hasAnyHardware, generatedYaml,
selectDevice, toggleHardware, togglePort, setPort5000Confirmed, selectDevice, toggleHardware, togglePort,
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
setRtspPassword, setTimezone, isHardwareDisabled, setRtspPassword, setTimezone, isHardwareDisabled,

View File

@ -1,42 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
/**
* Hook for a countdown timer (e.g. cooldown before confirming port 5000).
*/
export function useCooldown(initialSeconds: number) {
const [remaining, setRemaining] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const start = useCallback(() => {
// Clear any existing timer
if (timerRef.current) clearInterval(timerRef.current);
setRemaining(initialSeconds);
timerRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return 0;
}
return prev - 1;
});
}, 1000);
}, [initialSeconds]);
const stop = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setRemaining(0);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
return { remaining, start, stop };
}

View File

@ -5,13 +5,15 @@ import logging
import random import random
import string import string
import time import time
import zipfile
from collections import deque
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Iterator, List, Optional
import psutil import psutil
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, StreamingResponse
from pathvalidate import sanitize_filepath from pathvalidate import sanitize_filename, sanitize_filepath
from peewee import DoesNotExist from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
@ -361,6 +363,136 @@ def get_export_case(case_id: str):
) )
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
class _StreamingZipBuffer:
"""File-like sink for ZipFile that exposes written bytes via drain().
ZipFile writes synchronously into this buffer; the generator drains the
queue between writes so StreamingResponse can yield bytes without
materializing the whole archive in memory.
"""
def __init__(self) -> None:
self._queue: deque[bytes] = deque()
self._offset = 0
def write(self, data: bytes) -> int:
if data:
self._queue.append(bytes(data))
self._offset += len(data)
return len(data)
def tell(self) -> int:
return self._offset
def flush(self) -> None:
pass
def drain(self) -> Iterator[bytes]:
while self._queue:
yield self._queue.popleft()
def _unique_archive_name(export: Export, used: set[str]) -> str:
base = sanitize_filename(export.name) if export.name else None
if not base:
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
candidate = f"{base}.mp4"
counter = 1
while candidate in used:
candidate = f"{base}_{counter}.mp4"
counter += 1
used.add(candidate)
return candidate
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
buffer = _StreamingZipBuffer()
used_names: set[str] = set()
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_STORED,
allowZip64=True,
) as archive:
for export in exports:
source = Path(export.video_path)
if not source.exists():
continue
arcname = _unique_archive_name(export, used_names)
with (
archive.open(arcname, mode="w", force_zip64=True) as entry,
source.open("rb") as src,
):
while True:
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
if not chunk:
break
entry.write(chunk)
yield from buffer.drain()
yield from buffer.drain()
yield from buffer.drain()
@router.get(
"/cases/{case_id}/download",
dependencies=[Depends(allow_any_authenticated())],
summary="Download export case as zip",
description="Streams a zip archive containing every completed export's mp4 for the given case.",
)
def download_export_case(
case_id: str,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
exports = list(
Export.select()
.where(
Export.export_case == case_id,
~Export.in_progress,
Export.camera << allowed_cameras,
)
.order_by(Export.date.asc())
)
if not exports:
return JSONResponse(
content={"success": False, "message": "No exports available to download."},
status_code=404,
)
archive_base = sanitize_filename(case.name) if case.name else ""
if not archive_base:
archive_base = case_id
return StreamingResponse(
_stream_case_archive(exports),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
},
)
@router.patch( @router.patch(
"/cases/{case_id}", "/cases/{case_id}",
response_model=GenericResponse, response_model=GenericResponse,

View File

@ -57,6 +57,7 @@ import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { import {
LuDownload,
LuFolderPlus, LuFolderPlus,
LuFolderX, LuFolderX,
LuPencil, LuPencil,
@ -777,54 +778,76 @@ function Exports() {
filters={["cameras"]} filters={["cameras"]}
onUpdateFilter={setExportFilter} onUpdateFilter={setExportFilter}
/> />
{isAdmin && ( <div className="flex items-center gap-1 md:gap-2">
<div className="flex items-center gap-1 md:gap-2"> {(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
<Button <Button
asChild
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
size="sm" size="sm"
aria-label={t("toolbar.addExport")} aria-label={t("button.download", { ns: "common" })}
onClick={() => setCaseForAddExport(selectedCase)}
> >
<LuPlus className="text-secondary-foreground" /> <a
{!isMobile && ( download
<div className="text-primary"> href={`${baseUrl}api/cases/${selectedCase.id}/download`}
{t("toolbar.addExport")} >
</div> <LuDownload className="text-secondary-foreground" />
)} {!isMobile && (
<div className="text-primary">
{t("button.download", { ns: "common" })}
</div>
)}
</a>
</Button> </Button>
<Button )}
className="flex items-center gap-2 p-2" {isAdmin && (
size="sm" <>
aria-label={t("toolbar.editCase")} <Button
onClick={() => className="flex items-center gap-2 p-2"
setCaseDialog({ size="sm"
mode: "edit", aria-label={t("toolbar.addExport")}
exportCase: selectedCase, onClick={() => setCaseForAddExport(selectedCase)}
}) >
} <LuPlus className="text-secondary-foreground" />
> {!isMobile && (
<LuPencil className="text-secondary-foreground" /> <div className="text-primary">
{!isMobile && ( {t("toolbar.addExport")}
<div className="text-primary"> </div>
{t("toolbar.editCase")} )}
</div> </Button>
)} <Button
</Button> className="flex items-center gap-2 p-2"
<Button size="sm"
className="flex items-center gap-2 p-2" aria-label={t("toolbar.editCase")}
size="sm" onClick={() =>
aria-label={t("toolbar.deleteCase")} setCaseDialog({
onClick={() => setCaseToDelete(selectedCase)} mode: "edit",
> exportCase: selectedCase,
<LuTrash2 className="text-secondary-foreground" /> })
{!isMobile && ( }
<div className="text-primary"> >
{t("toolbar.deleteCase")} <LuPencil className="text-secondary-foreground" />
</div> {!isMobile && (
)} <div className="text-primary">
</Button> {t("toolbar.editCase")}
</div> </div>
)} )}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.deleteCase")}
onClick={() => setCaseToDelete(selectedCase)}
>
<LuTrash2 className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.deleteCase")}
</div>
)}
</Button>
</>
)}
</div>
</div> </div>
)} )}
</> </>