+
+
{t('Grid.Title')}
+
+ {layouts?.map((l) => (
+
+ ))}
+ {!editing && can('Manage') && !restricted && (
+
+ )}
+
+ {active && !editing && can('Manage') && !restricted && (
+
+ )}
+ {editing && (
+
+
+
+
+
+
+
+ )}
+
+
+ {editing && (
+
+
+ {t('Grid.Size')}:
+
+ {SIDES.map((n) => (
+
+ ))}
+
+ )}
+
+ {loading ? (
+
{t('Common.Loading')}
+ ) : !active ? (
+
{t('Grid.NoLayouts')}
+ ) : editing ? (
+ <>
+ {pageCount > 1 && (
+
+ {t('Grid.EditingPage')} {safePage + 1} / {pageCount}
+
+ )}
+
+ {draftTiles.map((camId, i) => (
+
+
+
+ ))}
+
+ >
+ ) : (
+ <>
+
+ {Array.from({ length: pageSize }, (_, i) => {
+ const id = tiles[pageStart + i]
+ const cam = id ? camById.get(id) : undefined
+ return cam ? :
+ })}
+
+ {pageCount > 1 && (
+
+
+ {Array.from({ length: pageCount }, (_, i) => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {dialog === 'create' && (
+
setDialog(null)}
+ />
+ )}
+ {dialog === 'rename' && active && (
+ setDialog(null)}
+ />
+ )}
+ {dialog === 'delete' && active && (
+ setDialog(null)}
+ />
+ )}
+
+ )
+}
diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx
new file mode 100644
index 0000000..6842f2c
--- /dev/null
+++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Groups.tsx
@@ -0,0 +1,131 @@
+import { useCallback, useEffect, useState } from 'react'
+import { api, ApiError, type GroupDto } from '../api'
+import { useI18n } from '../i18n'
+import { useAuth } from '../auth'
+import { ConfirmModal, TextPromptModal } from '../components/Modals'
+
+// Camera-group CRUD against /api/v1/groups. Deleting a group cameras still use
+// returns 409 group_in_use — surfaced inline rather than swallowed.
+export function Groups() {
+ const { t } = useI18n()
+ const { can } = useAuth()
+ const [groups, setGroups] = useState