diff --git a/CHANGELOG.md b/CHANGELOG.md index 5118a21..7f5dcb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ui.ts`). The original `index.ts` is now a barrel that re-exports everything, so all existing `@/types` imports keep working unchanged. New code can also import from a specific module (e.g. `@/types/admin`). +- Frontend layout polish on top of the directory restructure: + `app/slices/` split into `shared/` (`authSlice`), `scanner/` + (`scannerSlice`, `callsSlice`, `shareSlice`), and `admin/` + (`adminSlice`, `activitySlice`); `components/admin/AdminLayout.tsx` + inlined into `pages/Admin.tsx` (replacing the 5-line shim); + `components/admin/NavigationGuardContext.tsx` relocated to + `hooks/admin/useNavigationGuard.tsx`; and `services/downloadFilename.ts` + moved to `services/util/downloadFilename.ts`. All call sites updated; + no runtime behaviour change. ### Fixed diff --git a/frontend/src/app/slices/activitySlice.ts b/frontend/src/app/slices/admin/activitySlice.ts similarity index 100% rename from frontend/src/app/slices/activitySlice.ts rename to frontend/src/app/slices/admin/activitySlice.ts diff --git a/frontend/src/app/slices/adminSlice.ts b/frontend/src/app/slices/admin/adminSlice.ts similarity index 100% rename from frontend/src/app/slices/adminSlice.ts rename to frontend/src/app/slices/admin/adminSlice.ts diff --git a/frontend/src/app/slices/callsSlice.test.ts b/frontend/src/app/slices/scanner/callsSlice.test.ts similarity index 99% rename from frontend/src/app/slices/callsSlice.test.ts rename to frontend/src/app/slices/scanner/callsSlice.test.ts index bfd1462..68c2ae8 100644 --- a/frontend/src/app/slices/callsSlice.test.ts +++ b/frontend/src/app/slices/scanner/callsSlice.test.ts @@ -18,7 +18,7 @@ import { setDownloadMode, setTranscript, resetFilters, -} from "@/app/slices/callsSlice"; +} from "@/app/slices/scanner/callsSlice"; const reducer = callsSlice.reducer; diff --git a/frontend/src/app/slices/callsSlice.ts b/frontend/src/app/slices/scanner/callsSlice.ts similarity index 100% rename from frontend/src/app/slices/callsSlice.ts rename to frontend/src/app/slices/scanner/callsSlice.ts diff --git a/frontend/src/app/slices/scannerSlice.test.ts b/frontend/src/app/slices/scanner/scannerSlice.test.ts similarity index 99% rename from frontend/src/app/slices/scannerSlice.test.ts rename to frontend/src/app/slices/scanner/scannerSlice.test.ts index 2bbcff5..7e6fef9 100644 --- a/frontend/src/app/slices/scannerSlice.test.ts +++ b/frontend/src/app/slices/scanner/scannerSlice.test.ts @@ -17,7 +17,7 @@ import { setAllTGs, setConfig, transcriptReceived, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; import type { Call, ScannerConfig } from "@/types"; const reducer = scannerSlice.reducer; diff --git a/frontend/src/app/slices/scannerSlice.ts b/frontend/src/app/slices/scanner/scannerSlice.ts similarity index 100% rename from frontend/src/app/slices/scannerSlice.ts rename to frontend/src/app/slices/scanner/scannerSlice.ts diff --git a/frontend/src/app/slices/shareSlice.ts b/frontend/src/app/slices/scanner/shareSlice.ts similarity index 100% rename from frontend/src/app/slices/shareSlice.ts rename to frontend/src/app/slices/scanner/shareSlice.ts diff --git a/frontend/src/app/slices/authSlice.test.ts b/frontend/src/app/slices/shared/authSlice.test.ts similarity index 98% rename from frontend/src/app/slices/authSlice.test.ts rename to frontend/src/app/slices/shared/authSlice.test.ts index 3835e91..758516b 100644 --- a/frontend/src/app/slices/authSlice.test.ts +++ b/frontend/src/app/slices/shared/authSlice.test.ts @@ -5,7 +5,7 @@ import { clearCredentials, setAuthReady, setSetupStatus, -} from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; const { reducer } = authSlice; diff --git a/frontend/src/app/slices/authSlice.ts b/frontend/src/app/slices/shared/authSlice.ts similarity index 100% rename from frontend/src/app/slices/authSlice.ts rename to frontend/src/app/slices/shared/authSlice.ts diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index f3d1870..9a8108b 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,9 +1,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { useDispatch, useSelector } from "react-redux"; import { api } from "@/app/api"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; export const store = configureStore({ reducer: { diff --git a/frontend/src/components/admin/ActivityPanel.tsx b/frontend/src/components/admin/ActivityPanel.tsx index 99e17df..db6f206 100644 --- a/frontend/src/components/admin/ActivityPanel.tsx +++ b/frontend/src/components/admin/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useAdminActivity } from "@/hooks/admin/useAdminActivity"; -import type { ChartBucket } from "@/app/slices/activitySlice"; +import type { ChartBucket } from "@/app/slices/admin/activitySlice"; import { Activity, Clock3, diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx deleted file mode 100644 index eed4b99..0000000 --- a/frontend/src/components/admin/AdminLayout.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { useState } from "react"; -import { - NavLink, - Routes, - Route, - Navigate, - useNavigate, - useLocation, -} from "react-router-dom"; -import { - NavigationGuardProvider, - useNavigationGuard, -} from "@/components/admin/NavigationGuardContext"; -import { - Activity, - Users, - Radio, - FolderTree, - Key, - FolderSearch, - ArrowDownToLine, - Settings, - ScrollText, - Wrench, - Share2, - LogOut, - Home, - Menu, - X, - AudioLines, -} from "lucide-react"; -import { useAppSelector, useAppDispatch } from "@/app/store"; -import { - selectToken, - selectRole, - clearCredentials, - usePostLogoutMutation, -} from "@/app/slices/authSlice"; -import { useAdminWebSocket } from "@/hooks/admin/useAdminWebSocket"; -import UsersPanel from "@/components/admin/UsersPanel"; -import SystemsPanel from "@/components/admin/SystemsPanel"; -import GroupsTagsPanel from "@/components/admin/GroupsTagsPanel"; -import ApiKeysPanel from "@/components/admin/ApiKeysPanel"; -import DirMonitorPanel from "@/components/admin/DirMonitorPanel"; -import DownstreamsPanel from "@/components/admin/DownstreamsPanel"; -import OptionsPanel from "@/components/admin/OptionsPanel"; -import LogsPanel from "@/components/admin/LogsPanel"; -import ToolsPanel from "@/components/admin/ToolsPanel"; -import WebhooksPanel from "@/components/admin/WebhooksPanel"; -import ActivityPanel from "@/components/admin/ActivityPanel"; -import SharedLinksPanel from "@/components/admin/SharedLinksPanel"; -import TranscriptionPanel from "@/components/admin/TranscriptionPanel"; - -const navItems = [ - { to: "/admin/activity", label: "Activity", icon: Activity }, - { to: "/admin/users", label: "Users", icon: Users }, - { to: "/admin/systems", label: "Systems", icon: Radio }, - { to: "/admin/groups", label: "Groups & Tags", icon: FolderTree }, - { to: "/admin/apikeys", label: "API Keys", icon: Key }, - { to: "/admin/dirmonitors", label: "Monitors", icon: FolderSearch }, - { to: "/admin/downstreams", label: "Downstreams", icon: ArrowDownToLine }, - { to: "/admin/shared-links", label: "Shared Links", icon: Share2 }, - { to: "/admin/transcription", label: "Transcription", icon: AudioLines }, - { to: "/admin/options", label: "Options", icon: Settings }, - { to: "/admin/logs", label: "Logs", icon: ScrollText }, - { to: "/admin/tools", label: "Tools", icon: Wrench }, -] as const; - -function SidebarContent({ - showLabels, - onSignOut, - onNavClick, -}: { - showLabels: boolean; - onSignOut: () => void; - onNavClick?: () => void; -}) { - const { requestNavigation } = useNavigationGuard(); - const navigate = useNavigate(); - - const handleClick = - (to: string, extra?: () => void) => - (e: React.MouseEvent) => { - e.preventDefault(); - if (requestNavigation(to)) { - navigate(to); - extra?.(); - } - }; - - return ( - - ); -} - -export default function AdminLayout() { - const token = useAppSelector(selectToken); - const role = useAppSelector(selectRole); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - const location = useLocation(); - const [drawerOpen, setDrawerOpen] = useState(false); - const [postLogout] = usePostLogoutMutation(); - - useAdminWebSocket(); - - if (!token) { - return ( - - ); - } - - if (role !== "admin") { - return ( -
-
🚫
-

Access Denied

-

- Your account does not have administrator privileges. Contact an admin - if you believe this is a mistake. -

- - Go to Scanner - -
- ); - } - - const handleSignOut = () => { - postLogout() - .unwrap() - .catch(() => {}) - .finally(() => { - dispatch(clearCredentials()); - navigate("/login", { replace: true }); - }); - }; - - return ( - -
- setDrawerOpen(e.target.checked)} - /> - - {/* Main content */} -
- {/* Mobile top bar */} -
-
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
- - {/* Sidebar */} -
-
-
-
- ); -} diff --git a/frontend/src/components/admin/ApiKeysPanel.test.tsx b/frontend/src/components/admin/ApiKeysPanel.test.tsx index 464e86a..f3ab741 100644 --- a/frontend/src/components/admin/ApiKeysPanel.test.tsx +++ b/frontend/src/components/admin/ApiKeysPanel.test.tsx @@ -5,9 +5,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import ApiKeysPanel from "@/components/admin/ApiKeysPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { AdminApiKey, AdminSystem } from "@/types"; diff --git a/frontend/src/components/admin/OptionsPanel.tsx b/frontend/src/components/admin/OptionsPanel.tsx index 8093b1c..a7840e7 100644 --- a/frontend/src/components/admin/OptionsPanel.tsx +++ b/frontend/src/components/admin/OptionsPanel.tsx @@ -11,7 +11,7 @@ import { useGetConfigQuery, useUpdateConfigMutation, } from "@/hooks/admin/useAdminWsOps"; -import { useNavigationGuard } from "@/components/admin/NavigationGuardContext"; +import { useNavigationGuard } from "@/hooks/admin/useNavigationGuard"; import type { AdminSetting } from "@/types"; // ─── Known setting keys and their input types ─── diff --git a/frontend/src/components/admin/RadioReferenceCard.tsx b/frontend/src/components/admin/RadioReferenceCard.tsx index da5ee78..0633af7 100644 --- a/frontend/src/components/admin/RadioReferenceCard.tsx +++ b/frontend/src/components/admin/RadioReferenceCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback } from "react"; import { Upload, CheckCircle, XCircle, AlertTriangle } from "lucide-react"; -import { useRrPreviewCSVMutation } from "@/app/slices/adminSlice"; +import { useRrPreviewCSVMutation } from "@/app/slices/admin/adminSlice"; import { useRrApplyMutation, useListSystemsQuery } from "@/hooks/admin/useAdminWsOps"; import type { RRPreviewResponse, diff --git a/frontend/src/components/admin/SystemsPanel.test.tsx b/frontend/src/components/admin/SystemsPanel.test.tsx index 4e51384..be6bb0b 100644 --- a/frontend/src/components/admin/SystemsPanel.test.tsx +++ b/frontend/src/components/admin/SystemsPanel.test.tsx @@ -5,9 +5,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import SystemsPanel from "@/components/admin/SystemsPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { AdminSystem } from "@/types"; diff --git a/frontend/src/components/admin/ToolsPanel.tsx b/frontend/src/components/admin/ToolsPanel.tsx index 4d744a1..7c27db8 100644 --- a/frontend/src/components/admin/ToolsPanel.tsx +++ b/frontend/src/components/admin/ToolsPanel.tsx @@ -11,7 +11,7 @@ import { useImportUnitsMutation, useImportGroupsMutation, useImportTagsMutation, -} from "@/app/slices/adminSlice"; +} from "@/app/slices/admin/adminSlice"; import { useLazyExportConfigQuery, useLazyExportTalkgroupsQuery, @@ -21,7 +21,7 @@ import { useImportConfigMutation, useListSystemsQuery, } from "@/hooks/admin/useAdminWsOps"; -import { selectToken } from "@/app/slices/authSlice"; +import { selectToken } from "@/app/slices/shared/authSlice"; import { useAppSelector } from "@/app/store"; import RadioReferenceCard from "@/components/admin/RadioReferenceCard"; diff --git a/frontend/src/components/admin/UsersPanel.test.tsx b/frontend/src/components/admin/UsersPanel.test.tsx index a40ca7c..8bf8c23 100644 --- a/frontend/src/components/admin/UsersPanel.test.tsx +++ b/frontend/src/components/admin/UsersPanel.test.tsx @@ -5,9 +5,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import UsersPanel from "@/components/admin/UsersPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { AdminUser, AdminSystem } from "@/types"; diff --git a/frontend/src/components/scanner/BookmarksPanel.test.tsx b/frontend/src/components/scanner/BookmarksPanel.test.tsx index 0e4dd93..2221d28 100644 --- a/frontend/src/components/scanner/BookmarksPanel.test.tsx +++ b/frontend/src/components/scanner/BookmarksPanel.test.tsx @@ -17,7 +17,7 @@ vi.mock("@/services/audio/player", () => ({ }, })); -vi.mock("@/app/slices/authSlice", () => ({ +vi.mock("@/app/slices/shared/authSlice", () => ({ selectToken: () => "fake-token", })); @@ -26,7 +26,7 @@ vi.mock("@/app/store", () => ({ selector({ scanner: { config: null } }), })); -vi.mock("@/app/slices/shareSlice", () => ({ +vi.mock("@/app/slices/scanner/shareSlice", () => ({ useShareCallMutation: () => [vi.fn(), {}], })); diff --git a/frontend/src/components/scanner/BookmarksPanel.tsx b/frontend/src/components/scanner/BookmarksPanel.tsx index e45ab92..45e7e00 100644 --- a/frontend/src/components/scanner/BookmarksPanel.tsx +++ b/frontend/src/components/scanner/BookmarksPanel.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { useGetBookmarkCallsQuery, useToggleBookmarkMutation } from "@/app/api"; import { useAppSelector } from "@/app/store"; -import { selectToken } from "@/app/slices/authSlice"; +import { selectToken } from "@/app/slices/shared/authSlice"; import { audioPlayer } from "@/services/audio/player"; -import { sanitizeDownloadFilename } from "@/services/downloadFilename"; +import { sanitizeDownloadFilename } from "@/services/util/downloadFilename"; import { ShareCallButton } from "@/components/scanner/ShareCallButton"; import { X, Play, Download, Star, ChevronDown } from "lucide-react"; import type { Call } from "@/types"; diff --git a/frontend/src/components/scanner/DisplayPanel.tsx b/frontend/src/components/scanner/DisplayPanel.tsx index 26ec420..fb1a1d9 100644 --- a/frontend/src/components/scanner/DisplayPanel.tsx +++ b/frontend/src/components/scanner/DisplayPanel.tsx @@ -9,7 +9,7 @@ import { import { Share2, Sun, Copy, X, ExternalLink } from "lucide-react"; import { BookmarkButton } from "@/components/scanner/BookmarkButton"; import { useGetBookmarkIDsQuery, useToggleBookmarkMutation } from "@/app/api"; -import { useShareCallMutation } from "@/app/slices/shareSlice"; +import { useShareCallMutation } from "@/app/slices/scanner/shareSlice"; import { HistoryPanel } from "@/components/scanner/HistoryPanel"; import { TranscriptPanel } from "@/components/scanner/TranscriptPanel"; import { useActiveUnit } from "@/hooks/scanner/useActiveUnit"; diff --git a/frontend/src/components/scanner/LEDPanel.test.tsx b/frontend/src/components/scanner/LEDPanel.test.tsx index b80b9b5..97c6d6d 100644 --- a/frontend/src/components/scanner/LEDPanel.test.tsx +++ b/frontend/src/components/scanner/LEDPanel.test.tsx @@ -4,9 +4,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import { LEDPanel } from "@/components/scanner/LEDPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; import type { Call, ScannerConfig } from "@/types"; diff --git a/frontend/src/components/scanner/LEDPanel.tsx b/frontend/src/components/scanner/LEDPanel.tsx index 2e3fcf1..b7eda59 100644 --- a/frontend/src/components/scanner/LEDPanel.tsx +++ b/frontend/src/components/scanner/LEDPanel.tsx @@ -17,8 +17,8 @@ import { selectUsername, clearCredentials, usePostLogoutMutation, -} from "@/app/slices/authSlice"; -import { useChangePasswordMutation } from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; +import { useChangePasswordMutation } from "@/app/slices/shared/authSlice"; export function LEDPanel() { const { isDark, toggle } = useTheme(); diff --git a/frontend/src/components/scanner/SearchPanel.test.tsx b/frontend/src/components/scanner/SearchPanel.test.tsx index 4b00d0d..9f6ec5e 100644 --- a/frontend/src/components/scanner/SearchPanel.test.tsx +++ b/frontend/src/components/scanner/SearchPanel.test.tsx @@ -3,9 +3,9 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import SearchPanel from "@/components/scanner/SearchPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; import type { ScannerConfig } from "@/types"; @@ -14,10 +14,10 @@ import type { ScannerConfig } from "@/types"; const mockSearchCallsQuery = vi.fn(); -vi.mock("@/app/slices/callsSlice", async () => { +vi.mock("@/app/slices/scanner/callsSlice", async () => { const actual = await vi.importActual< - typeof import("@/app/slices/callsSlice") - >("@/app/slices/callsSlice"); + typeof import("@/app/slices/scanner/callsSlice") + >("@/app/slices/scanner/callsSlice"); return { ...actual, useSearchCallsQuery: (...args: unknown[]) => mockSearchCallsQuery(...args), diff --git a/frontend/src/components/scanner/SearchPanel.tsx b/frontend/src/components/scanner/SearchPanel.tsx index 73cb7f9..b209835 100644 --- a/frontend/src/components/scanner/SearchPanel.tsx +++ b/frontend/src/components/scanner/SearchPanel.tsx @@ -33,11 +33,11 @@ import { setBookmarkedOnly, setTranscript, resetFilters, -} from "@/app/slices/callsSlice"; +} from "@/app/slices/scanner/callsSlice"; import { useGetBookmarkIDsQuery, useToggleBookmarkMutation } from "@/app/api"; -import { selectToken } from "@/app/slices/authSlice"; +import { selectToken } from "@/app/slices/shared/authSlice"; import { audioPlayer } from "@/services/audio/player"; -import { sanitizeDownloadFilename } from "@/services/downloadFilename"; +import { sanitizeDownloadFilename } from "@/services/util/downloadFilename"; import type { Call } from "@/types"; interface SearchPanelProps { diff --git a/frontend/src/components/scanner/SelectTGPanel.test.tsx b/frontend/src/components/scanner/SelectTGPanel.test.tsx index 5843040..5ae40da 100644 --- a/frontend/src/components/scanner/SelectTGPanel.test.tsx +++ b/frontend/src/components/scanner/SelectTGPanel.test.tsx @@ -3,9 +3,9 @@ import { render, screen, fireEvent, within } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import SelectTGPanel from "@/components/scanner/SelectTGPanel"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; import type { ScannerConfig } from "@/types"; diff --git a/frontend/src/components/scanner/SelectTGPanel.tsx b/frontend/src/components/scanner/SelectTGPanel.tsx index facffe9..4257c0f 100644 --- a/frontend/src/components/scanner/SelectTGPanel.tsx +++ b/frontend/src/components/scanner/SelectTGPanel.tsx @@ -8,7 +8,7 @@ import { setTGsByGroup, setTGsByTag, removeAvoid, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; import type { TalkgroupConfig, AvoidEntry } from "@/types"; interface SelectTGPanelProps { diff --git a/frontend/src/components/scanner/ShareCallButton.tsx b/frontend/src/components/scanner/ShareCallButton.tsx index e67701d..5105bd1 100644 --- a/frontend/src/components/scanner/ShareCallButton.tsx +++ b/frontend/src/components/scanner/ShareCallButton.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Share2, Copy, ExternalLink, X } from "lucide-react"; -import { useShareCallMutation } from "@/app/slices/shareSlice"; +import { useShareCallMutation } from "@/app/slices/scanner/shareSlice"; interface ShareCallButtonProps { callId: number; diff --git a/frontend/src/hooks/admin/index.ts b/frontend/src/hooks/admin/index.ts index 70d3b46..ba54fe1 100644 --- a/frontend/src/hooks/admin/index.ts +++ b/frontend/src/hooks/admin/index.ts @@ -2,4 +2,5 @@ export * from "./useAdminActivity"; export * from "./useAdminLogs"; export * from "./useAdminWebSocket"; export * from "./useAdminWsOps"; +export * from "./useNavigationGuard"; export * from "./useWsQuery"; diff --git a/frontend/src/hooks/admin/useAdminActivity.ts b/frontend/src/hooks/admin/useAdminActivity.ts index bc645b0..a809c6e 100644 --- a/frontend/src/hooks/admin/useAdminActivity.ts +++ b/frontend/src/hooks/admin/useAdminActivity.ts @@ -4,7 +4,7 @@ import type { ActivityStats, ActivityChartResponse, TopTalkgroupsResponse, -} from "@/app/slices/activitySlice"; +} from "@/app/slices/admin/activitySlice"; const REFRESH_INTERVAL = 30_000; const DEBOUNCE_MS = 3_000; // debounce rapid call bursts diff --git a/frontend/src/hooks/admin/useAdminWebSocket.ts b/frontend/src/hooks/admin/useAdminWebSocket.ts index 2b43826..1e274e5 100644 --- a/frontend/src/hooks/admin/useAdminWebSocket.ts +++ b/frontend/src/hooks/admin/useAdminWebSocket.ts @@ -1,7 +1,7 @@ import { useEffect, useCallback } from "react"; import { useAppDispatch, useAppSelector } from "@/app/store"; import { adminWsClient } from "@/services/ws/adminClient"; -import { setCredentials, usePostRefreshMutation } from "@/app/slices/authSlice"; +import { setCredentials, usePostRefreshMutation } from "@/app/slices/shared/authSlice"; import { api } from "@/app/api"; export function useAdminWebSocket(): void { diff --git a/frontend/src/components/admin/NavigationGuardContext.tsx b/frontend/src/hooks/admin/useNavigationGuard.tsx similarity index 100% rename from frontend/src/components/admin/NavigationGuardContext.tsx rename to frontend/src/hooks/admin/useNavigationGuard.tsx diff --git a/frontend/src/hooks/scanner/useAudioPlayer.ts b/frontend/src/hooks/scanner/useAudioPlayer.ts index 68f44f0..7895bf5 100644 --- a/frontend/src/hooks/scanner/useAudioPlayer.ts +++ b/frontend/src/hooks/scanner/useAudioPlayer.ts @@ -7,7 +7,7 @@ import { setCurrentCall, clearCurrentCall, setAudioActive, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; export function useAudioPlayer() { const dispatch = useAppDispatch(); diff --git a/frontend/src/hooks/scanner/useScanner.ts b/frontend/src/hooks/scanner/useScanner.ts index b2e4837..aa9bf95 100644 --- a/frontend/src/hooks/scanner/useScanner.ts +++ b/frontend/src/hooks/scanner/useScanner.ts @@ -13,7 +13,7 @@ import { toggleTG, setAllTGs, setTGsBySystem, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; import type { AvoidEntry } from "@/types"; export function useScanner() { diff --git a/frontend/src/hooks/scanner/useTGSelectionSync.ts b/frontend/src/hooks/scanner/useTGSelectionSync.ts index d235fab..c2adad5 100644 --- a/frontend/src/hooks/scanner/useTGSelectionSync.ts +++ b/frontend/src/hooks/scanner/useTGSelectionSync.ts @@ -6,12 +6,12 @@ import { restoreFromDisabledTGs, restoreAvoidList, resetTGSelection, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; import { selectToken, useGetTGSelectionQuery, useUpdateTGSelectionMutation, -} from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; import type { AvoidEntry } from "@/types"; function storageKey(instanceId: string): string { diff --git a/frontend/src/hooks/shared/useAuthInit.test.tsx b/frontend/src/hooks/shared/useAuthInit.test.tsx index 2a8dec4..556f598 100644 --- a/frontend/src/hooks/shared/useAuthInit.test.tsx +++ b/frontend/src/hooks/shared/useAuthInit.test.tsx @@ -3,9 +3,9 @@ import { render, waitFor, act } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; import { useAuthInit } from "@/hooks/shared/useAuthInit"; @@ -13,9 +13,9 @@ import { useAuthInit } from "@/hooks/shared/useAuthInit"; // ── Mock the refresh mutation ───────────────────────────────────────────── const mockPostRefresh = vi.fn(); -vi.mock("@/app/slices/authSlice", async () => { - const actual = await vi.importActual( - "@/app/slices/authSlice", +vi.mock("@/app/slices/shared/authSlice", async () => { + const actual = await vi.importActual( + "@/app/slices/shared/authSlice", ); return { ...actual, diff --git a/frontend/src/hooks/shared/useAuthInit.ts b/frontend/src/hooks/shared/useAuthInit.ts index cc61599..340cf48 100644 --- a/frontend/src/hooks/shared/useAuthInit.ts +++ b/frontend/src/hooks/shared/useAuthInit.ts @@ -4,7 +4,7 @@ import { setCredentials, setAuthReady, usePostRefreshMutation, -} from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; /** * Attempts a silent token refresh on app mount. diff --git a/frontend/src/hooks/shared/useTokenRefresh.test.tsx b/frontend/src/hooks/shared/useTokenRefresh.test.tsx index 484e8e5..e7ad0ca 100644 --- a/frontend/src/hooks/shared/useTokenRefresh.test.tsx +++ b/frontend/src/hooks/shared/useTokenRefresh.test.tsx @@ -2,18 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, act } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice, setCredentials } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice, setCredentials } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import { useTokenRefresh } from "@/hooks/shared/useTokenRefresh"; // ── Mocks ──────────────────────────────────────────────────────────────── const mockPostRefresh = vi.fn(); -vi.mock("@/app/slices/authSlice", async () => { - const actual = await vi.importActual( - "@/app/slices/authSlice", +vi.mock("@/app/slices/shared/authSlice", async () => { + const actual = await vi.importActual( + "@/app/slices/shared/authSlice", ); return { ...actual, diff --git a/frontend/src/hooks/shared/useTokenRefresh.ts b/frontend/src/hooks/shared/useTokenRefresh.ts index 808e83c..a9294ec 100644 --- a/frontend/src/hooks/shared/useTokenRefresh.ts +++ b/frontend/src/hooks/shared/useTokenRefresh.ts @@ -5,7 +5,7 @@ import { setCredentials, clearCredentials, usePostRefreshMutation, -} from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; /** * Schedules a silent access token refresh 1 minute before the current diff --git a/frontend/src/hooks/shared/useWebSocket.ts b/frontend/src/hooks/shared/useWebSocket.ts index 33f9bb3..0fdc3d2 100644 --- a/frontend/src/hooks/shared/useWebSocket.ts +++ b/frontend/src/hooks/shared/useWebSocket.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useCallback } from "react"; import { useAppDispatch, useAppSelector } from "@/app/store"; import { wsClient } from "@/services/ws/client"; -import { setCredentials, usePostRefreshMutation } from "@/app/slices/authSlice"; +import { setCredentials, usePostRefreshMutation } from "@/app/slices/shared/authSlice"; import type { ConnectionStatus } from "@/types"; export function useWebSocket(): { connectionStatus: ConnectionStatus } { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 84365bd..9758305 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,7 @@ import { Provider } from "react-redux"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { store } from "@/app/store"; import { useAppSelector } from "@/app/store"; -import { selectAuthReady } from "@/app/slices/authSlice"; +import { selectAuthReady } from "@/app/slices/shared/authSlice"; import { useAuthInit } from "@/hooks/shared/useAuthInit"; import { useTokenRefresh } from "@/hooks/shared/useTokenRefresh"; import "@/index.css"; diff --git a/frontend/src/components/admin/AdminLayout.test.tsx b/frontend/src/pages/Admin.test.tsx similarity index 92% rename from frontend/src/components/admin/AdminLayout.test.tsx rename to frontend/src/pages/Admin.test.tsx index e1ea091..77c5c86 100644 --- a/frontend/src/components/admin/AdminLayout.test.tsx +++ b/frontend/src/pages/Admin.test.tsx @@ -3,10 +3,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; -import AdminLayout from "@/components/admin/AdminLayout"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import Admin from "@/pages/Admin"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; @@ -49,7 +49,7 @@ function renderAdmin(preloadedState?: Partial) { ...render( - + , ), @@ -58,7 +58,7 @@ function renderAdmin(preloadedState?: Partial) { // --- Tests --- -describe("AdminLayout", () => { +describe("Admin", () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 7edd655..a4b9f95 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,5 +1,260 @@ -import AdminLayout from "@/components/admin/AdminLayout"; +import { useState } from "react"; +import { + NavLink, + Routes, + Route, + Navigate, + useNavigate, + useLocation, +} from "react-router-dom"; +import { + NavigationGuardProvider, + useNavigationGuard, +} from "@/hooks/admin/useNavigationGuard"; +import { + Activity, + Users, + Radio, + FolderTree, + Key, + FolderSearch, + ArrowDownToLine, + Settings, + ScrollText, + Wrench, + Share2, + LogOut, + Home, + Menu, + X, + AudioLines, +} from "lucide-react"; +import { useAppSelector, useAppDispatch } from "@/app/store"; +import { + selectToken, + selectRole, + clearCredentials, + usePostLogoutMutation, +} from "@/app/slices/shared/authSlice"; +import { useAdminWebSocket } from "@/hooks/admin/useAdminWebSocket"; +import UsersPanel from "@/components/admin/UsersPanel"; +import SystemsPanel from "@/components/admin/SystemsPanel"; +import GroupsTagsPanel from "@/components/admin/GroupsTagsPanel"; +import ApiKeysPanel from "@/components/admin/ApiKeysPanel"; +import DirMonitorPanel from "@/components/admin/DirMonitorPanel"; +import DownstreamsPanel from "@/components/admin/DownstreamsPanel"; +import OptionsPanel from "@/components/admin/OptionsPanel"; +import LogsPanel from "@/components/admin/LogsPanel"; +import ToolsPanel from "@/components/admin/ToolsPanel"; +import WebhooksPanel from "@/components/admin/WebhooksPanel"; +import ActivityPanel from "@/components/admin/ActivityPanel"; +import SharedLinksPanel from "@/components/admin/SharedLinksPanel"; +import TranscriptionPanel from "@/components/admin/TranscriptionPanel"; + +const navItems = [ + { to: "/admin/activity", label: "Activity", icon: Activity }, + { to: "/admin/users", label: "Users", icon: Users }, + { to: "/admin/systems", label: "Systems", icon: Radio }, + { to: "/admin/groups", label: "Groups & Tags", icon: FolderTree }, + { to: "/admin/apikeys", label: "API Keys", icon: Key }, + { to: "/admin/dirmonitors", label: "Monitors", icon: FolderSearch }, + { to: "/admin/downstreams", label: "Downstreams", icon: ArrowDownToLine }, + { to: "/admin/shared-links", label: "Shared Links", icon: Share2 }, + { to: "/admin/transcription", label: "Transcription", icon: AudioLines }, + { to: "/admin/options", label: "Options", icon: Settings }, + { to: "/admin/logs", label: "Logs", icon: ScrollText }, + { to: "/admin/tools", label: "Tools", icon: Wrench }, +] as const; + +function SidebarContent({ + showLabels, + onSignOut, + onNavClick, +}: { + showLabels: boolean; + onSignOut: () => void; + onNavClick?: () => void; +}) { + const { requestNavigation } = useNavigationGuard(); + const navigate = useNavigate(); + + const handleClick = + (to: string, extra?: () => void) => + (e: React.MouseEvent) => { + e.preventDefault(); + if (requestNavigation(to)) { + navigate(to); + extra?.(); + } + }; + + return ( +
    + {navItems.map(({ to, label, icon: Icon }) => ( +
  • + + isActive + ? "border-l-4 border-primary bg-primary/10" + : "hover:bg-base-300" + } + > + + {showLabels && {label}} + +
  • + ))} +
  • + + + {showLabels && Scanner} + +
  • +
  • + +
  • +
+ ); +} export default function Admin() { - return ; + const token = useAppSelector(selectToken); + const role = useAppSelector(selectRole); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [postLogout] = usePostLogoutMutation(); + + useAdminWebSocket(); + + if (!token) { + return ( + + ); + } + + if (role !== "admin") { + return ( +
+
🚫
+

Access Denied

+

+ Your account does not have administrator privileges. Contact an admin + if you believe this is a mistake. +

+ + Go to Scanner + +
+ ); + } + + const handleSignOut = () => { + postLogout() + .unwrap() + .catch(() => {}) + .finally(() => { + dispatch(clearCredentials()); + navigate("/login", { replace: true }); + }); + }; + + return ( + +
+ setDrawerOpen(e.target.checked)} + /> + + {/* Main content */} +
+ {/* Mobile top bar */} +
+
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ + {/* Sidebar */} +
+
+
+
+ ); } diff --git a/frontend/src/pages/Login.test.tsx b/frontend/src/pages/Login.test.tsx index 88dc816..1c3ab69 100644 --- a/frontend/src/pages/Login.test.tsx +++ b/frontend/src/pages/Login.test.tsx @@ -4,9 +4,9 @@ import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import Login from "@/pages/Login"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; @@ -36,9 +36,9 @@ vi.mock("@/app/api", async () => { useGetSetupStatusQuery: () => mockUseGetSetupStatusQuery(), }; }); -vi.mock("@/app/slices/authSlice", async () => { - const actual = await vi.importActual( - "@/app/slices/authSlice", +vi.mock("@/app/slices/shared/authSlice", async () => { + const actual = await vi.importActual( + "@/app/slices/shared/authSlice", ); return { ...actual, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 2c338aa..68847e9 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -8,7 +8,7 @@ import { selectToken, usePostLoginMutation, useChangePasswordMutation, -} from "@/app/slices/authSlice"; +} from "@/app/slices/shared/authSlice"; interface LoginLocationState { from?: string; diff --git a/frontend/src/pages/Scanner.tsx b/frontend/src/pages/Scanner.tsx index 2de844b..862e9ad 100644 --- a/frontend/src/pages/Scanner.tsx +++ b/frontend/src/pages/Scanner.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useGetSetupStatusQuery } from "@/app/api"; import { useAppDispatch, useAppSelector } from "@/app/store"; -import { setSetupStatus, selectToken } from "@/app/slices/authSlice"; +import { setSetupStatus, selectToken } from "@/app/slices/shared/authSlice"; import { expireAvoids, setPaused, setLive, resetDisplay, -} from "@/app/slices/scannerSlice"; +} from "@/app/slices/scanner/scannerSlice"; import { useScanner } from "@/hooks/scanner/useScanner"; import { useTGSelectionSync } from "@/hooks/scanner/useTGSelectionSync"; import { LEDPanel } from "@/components/scanner/LEDPanel"; diff --git a/frontend/src/pages/Setup.test.tsx b/frontend/src/pages/Setup.test.tsx index 5535f27..579fc8e 100644 --- a/frontend/src/pages/Setup.test.tsx +++ b/frontend/src/pages/Setup.test.tsx @@ -3,9 +3,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; import Setup from "@/pages/Setup"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; import type { RootState } from "@/app/store"; diff --git a/frontend/src/pages/SharedCall.test.tsx b/frontend/src/pages/SharedCall.test.tsx index 41b6901..66d9a59 100644 --- a/frontend/src/pages/SharedCall.test.tsx +++ b/frontend/src/pages/SharedCall.test.tsx @@ -11,7 +11,7 @@ vi.mock("react-router-dom", async () => { }); const mockUseGetSharedCallQuery = vi.fn(); -vi.mock("@/app/slices/shareSlice", () => ({ +vi.mock("@/app/slices/scanner/shareSlice", () => ({ useGetSharedCallQuery: (...args: unknown[]) => mockUseGetSharedCallQuery(...args), })); diff --git a/frontend/src/pages/SharedCall.tsx b/frontend/src/pages/SharedCall.tsx index e6d5e8b..264138b 100644 --- a/frontend/src/pages/SharedCall.tsx +++ b/frontend/src/pages/SharedCall.tsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetSharedCallQuery } from "@/app/slices/shareSlice"; +import { useGetSharedCallQuery } from "@/app/slices/scanner/shareSlice"; import { Download, Radio } from "lucide-react"; function formatDuration(secs: number): string { diff --git a/frontend/src/services/downloadFilename.ts b/frontend/src/services/util/downloadFilename.ts similarity index 100% rename from frontend/src/services/downloadFilename.ts rename to frontend/src/services/util/downloadFilename.ts diff --git a/frontend/src/services/ws/adminClient.ts b/frontend/src/services/ws/adminClient.ts index 43f6b14..8308dc3 100644 --- a/frontend/src/services/ws/adminClient.ts +++ b/frontend/src/services/ws/adminClient.ts @@ -1,5 +1,5 @@ import type { AppDispatch } from "@/app/store"; -import { clearCredentials } from "@/app/slices/authSlice"; +import { clearCredentials } from "@/app/slices/shared/authSlice"; type TokenExpiredCallback = () => Promise; type EventCallback = (topic: string, data: unknown, at: number) => void; diff --git a/frontend/src/services/ws/client.test.ts b/frontend/src/services/ws/client.test.ts index d0ea66c..ab6b52c 100644 --- a/frontend/src/services/ws/client.test.ts +++ b/frontend/src/services/ws/client.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { wsClient } from "@/services/ws/client"; import { configureStore } from "@reduxjs/toolkit"; -import { scannerSlice } from "@/app/slices/scannerSlice"; -import { authSlice } from "@/app/slices/authSlice"; -import { callsSlice } from "@/app/slices/callsSlice"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; import { api } from "@/app/api"; // ── Fake WebSocket stub ─────────────────────────────────────────────────── diff --git a/frontend/src/services/ws/client.ts b/frontend/src/services/ws/client.ts index 1e5383e..9c53bd7 100644 --- a/frontend/src/services/ws/client.ts +++ b/frontend/src/services/ws/client.ts @@ -6,8 +6,8 @@ import { setListenerCount, setConnectionStatus, transcriptReceived, -} from "@/app/slices/scannerSlice"; -import { clearCredentials } from "@/app/slices/authSlice"; +} from "@/app/slices/scanner/scannerSlice"; +import { clearCredentials } from "@/app/slices/shared/authSlice"; import type { Call, WsCommand, TranscriptionSegment } from "@/types"; const MAX_BACKOFF = 30_000;