mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-10 21:01:10 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a54d1bc4 | ||
|
|
6f80bcd19f | ||
|
|
d02a1156b7 | ||
|
|
c17538aff9 | ||
|
|
dd7e9f1bc5 |
@@ -90,7 +90,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.4.0
|
||||
uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
|
||||
@@ -262,6 +262,19 @@ In this example:
|
||||
|
||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||
|
||||
:::note
|
||||
|
||||
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
frigate.api.auth: debug
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
|
||||
|
||||
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
|
||||
|
||||
### How do I make a YAML profile track no objects at all?
|
||||
|
||||
Set the tracked object list explicitly to an empty list in the profile:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
profiles:
|
||||
home:
|
||||
objects:
|
||||
track: []
|
||||
```
|
||||
|
||||
Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`.
|
||||
|
||||
### Why are some settings missing when I configure a profile override?
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
@@ -428,3 +428,19 @@ You'll want to:
|
||||
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
|
||||
|
||||
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
|
||||
|
||||
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
|
||||
|
||||
This is expected behavior, not a bug:
|
||||
|
||||
- Previews for hours that already completed and were written to disk are unaffected.
|
||||
- The next full hour after a restart will generate previews normally.
|
||||
- This is unrelated to `shm_size`; increasing shared memory does not change it.
|
||||
|
||||
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
|
||||
- **Rename** it, and
|
||||
- **Delete** it: deleting is the only way an export is removed.
|
||||
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases).
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
|
||||
|
||||
## Cases
|
||||
|
||||
|
||||
Vendored
+5
@@ -8244,6 +8244,11 @@ components:
|
||||
properties:
|
||||
provider:
|
||||
$ref: '#/components/schemas/GenAIProviderEnum'
|
||||
name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Name
|
||||
api_key:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
+9
-2
@@ -196,7 +196,7 @@ def genai_models(request: Request):
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
async def genai_probe(request: Request, body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
@@ -206,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
content={"success": False, "message": "Unknown provider"},
|
||||
)
|
||||
|
||||
api_key = body.api_key
|
||||
if api_key == REDACTED_CREDENTIAL_SENTINEL:
|
||||
saved_cfg = (
|
||||
request.app.frigate_config.genai.get(body.name) if body.name else None
|
||||
)
|
||||
api_key = saved_cfg.api_key if saved_cfg else None
|
||||
|
||||
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
@@ -217,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
api_key=api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# model is required by the schema but irrelevant for listing.
|
||||
|
||||
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
name: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
provider_options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||
]
|
||||
|
||||
if face_frame.size == 0:
|
||||
logger.debug(f"Empty face crop for {id}")
|
||||
return
|
||||
|
||||
res = self.recognizer.classify(face_frame)
|
||||
|
||||
if not res:
|
||||
|
||||
@@ -383,11 +383,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
return None
|
||||
|
||||
# this segment has a valid duration and has video data, so publish an update
|
||||
# publish the segment end time so the watchdog measures the age of the
|
||||
# newest validated video data; the start time is already ~2x segment
|
||||
# duration old by the time the next segment finishes and is validated
|
||||
self.recordings_publisher.publish(
|
||||
(camera, end_time.timestamp(), cache_path),
|
||||
(camera, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
|
||||
"models": ["fake-model-a", "fake-model-b"],
|
||||
}
|
||||
|
||||
def test_genai_probe_resolves_sentinel_to_saved_api_key(self):
|
||||
# After a save the UI's api_key field holds the redaction sentinel;
|
||||
# the probe must substitute the saved key for the named entry instead
|
||||
# of sending the literal sentinel to the provider (GH discussion 23754).
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
self.minimal_config["genai"] = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-saved",
|
||||
"base_url": "https://example.invalid",
|
||||
"model": "fake-model",
|
||||
}
|
||||
}
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
"base_url": "https://example.invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert probed_keys == ["sk-saved"]
|
||||
|
||||
def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self):
|
||||
# If the sentinel arrives for an entry that has no saved config, the
|
||||
# probe must drop the key entirely rather than leak the sentinel.
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert probed_keys == [None]
|
||||
|
||||
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
|
||||
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
|
||||
self.rtsp_with_special_pass = (
|
||||
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
|
||||
)
|
||||
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
|
||||
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
|
||||
|
||||
def test_cleanup(self):
|
||||
|
||||
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_notification_test(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Admin access ---
|
||||
|
||||
def test_admin_can_send_restart(self):
|
||||
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_admin_can_send_notification_test(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
|
||||
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# check for config updates
|
||||
updated_topics = self.camera_config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_topics:
|
||||
for camera in updated_topics["enabled"]:
|
||||
if self.camera_states[camera].prev_enabled is None:
|
||||
self.camera_states[camera].prev_enabled = self.config.cameras[
|
||||
camera
|
||||
].enabled
|
||||
elif "add" in updated_topics:
|
||||
for camera in updated_topics["add"]:
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
# a single drain can carry several topics at once, so add and
|
||||
# remove are handled independently rather than as exclusive branches
|
||||
for camera in updated_topics.get("add", []):
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
if "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
name: providerKey,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
@@ -227,16 +228,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
"min-w-0 justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
<span className="truncate">
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -263,12 +266,14 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
@@ -287,11 +292,11 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
<span className="truncate">{model}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
|
||||
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
|
||||
</Label>
|
||||
<Switch
|
||||
key={item}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ChipProps = {
|
||||
className?: string;
|
||||
@@ -50,6 +51,7 @@ type LogChipProps = {
|
||||
onClickSeverity?: () => void;
|
||||
};
|
||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -444,10 +444,10 @@ export function ExportContent({
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: latestTime - 3600,
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
}, [activeTab, latestTime, range, setRange]);
|
||||
}, [activeTab, currentTime, range, setRange]);
|
||||
|
||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||
activeTab === "multi" && debouncedRange
|
||||
@@ -817,7 +817,19 @@ export function ExportContent({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ExportTab)}
|
||||
onValueChange={(value) => {
|
||||
const tab = value as ExportTab;
|
||||
if (tab === "multi") {
|
||||
setRange({
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
} else {
|
||||
onSelectTime(selectedOption);
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
|
||||
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn(
|
||||
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -770,6 +770,34 @@ export default function MotionSearchView({
|
||||
};
|
||||
}, [cancelMotionSearchJobViaBeacon]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}, [navigate, onBack]);
|
||||
|
||||
// Dismissing the entry dialog (escape / click outside) before a search has
|
||||
// run leaves nothing behind it, so cancel the flow instead of revealing an
|
||||
// empty page.
|
||||
const handleSearchDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (
|
||||
!nextOpen &&
|
||||
!isSearching &&
|
||||
!hasSearched &&
|
||||
searchResults.length === 0
|
||||
) {
|
||||
handleBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchDialogOpen(nextOpen);
|
||||
},
|
||||
[handleBack, hasSearched, isSearching, searchResults.length],
|
||||
);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
if (jobId && jobCamera) {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
|
||||
<Toaster closeButton={true} position="top-center" />
|
||||
<MotionSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onOpenChange={handleSearchDialogOpenChange}
|
||||
config={config}
|
||||
cameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => (onBack ? onBack() : navigate(-1))}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
|
||||
Reference in New Issue
Block a user