Skip to content

Commit eca4ed0

Browse files
committed
refactor: extract StatisticsDashboard tabs and colocate BrowseRecords components
- Extract StatisticsDashboard into 4 tab components (OverviewTab, LanguageSetsTab, UserStatisticsTab, HighScoresTab) reducing from 833 to 459 lines - Move all BrowseRecords-specific components from AdminPanel/ to BrowseRecords/ directory - Move corresponding test files alongside their components - Export computeControlBarMode from useAdminLayout hook and update test import - Clean up dead code from AdminPanel.jsx (batch state, notification state, unused imports)
1 parent 0d2b659 commit eca4ed0

37 files changed

+1321
-1093
lines changed

frontend/src/features/admin/components/AdminPanel/AdminPanel.jsx

Lines changed: 102 additions & 643 deletions
Large diffs are not rendered by default.

frontend/src/features/admin/components/AdminPanel/EditRowForm.jsx

Lines changed: 0 additions & 3 deletions
This file was deleted.

frontend/src/features/admin/components/AdminPanel/__tests__/AdminPanel.responsive.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computeControlBarMode } from '../AdminPanel';
1+
import { computeControlBarMode } from '../../../hooks/useAdminLayout';
22

33
describe('AdminPanel responsive control bar mode', () => {
44
it('uses compact mode for narrow layouts', () => {

frontend/src/features/admin/components/AdminPanel/AdminTable.jsx renamed to frontend/src/features/admin/components/BrowseRecords/AdminTable.jsx

File renamed without changes.

frontend/src/features/admin/components/AdminPanel/BatchOperationDialog.jsx renamed to frontend/src/features/admin/components/BrowseRecords/BatchOperationDialog.jsx

File renamed without changes.

frontend/src/features/admin/components/AdminPanel/BatchOperationsToolbar.jsx renamed to frontend/src/features/admin/components/BrowseRecords/BatchOperationsToolbar.jsx

File renamed without changes.

frontend/src/features/admin/components/AdminPanel/BatchResultDialog.jsx renamed to frontend/src/features/admin/components/BrowseRecords/BatchResultDialog.jsx

File renamed without changes.
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import React, { useState, useEffect, useCallback } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useAdminApi } from '../AdminPanel/useAdminApi';
4+
import BrowseRecordsView from './BrowseRecordsView';
5+
import { STORAGE_KEYS } from '../../../../shared/constants/constants';
6+
7+
export default function BrowseRecordsContainer({
8+
token,
9+
currentUser,
10+
selectedLanguageSetId,
11+
languageSets,
12+
setSelectedLanguageSetId,
13+
categories,
14+
userIgnoredCategories,
15+
ignoredCategories,
16+
onUpdateUserIgnoredCategories,
17+
setDashboard,
18+
setToken,
19+
setIsLogged,
20+
isControlBarCollapsed,
21+
isLayoutCompact
22+
}) {
23+
const { t } = useTranslation();
24+
25+
// Data State
26+
const [rows, setRows] = useState([]);
27+
const [totalRows, setTotalRows] = useState(0);
28+
const [offset, setOffset] = useState(0);
29+
const [limit, setLimit] = useState(() => {
30+
const saved = localStorage.getItem(STORAGE_KEYS.ADMIN_PAGE_SIZE);
31+
return saved ? parseInt(saved) : 20;
32+
});
33+
const [error, setError] = useState("");
34+
const [offsetInput, setOffsetInput] = useState(0);
35+
const [searchTerm, setSearchTerm] = useState('');
36+
const [filterCategory, setFilterCategory] = useState('');
37+
const [loading, setLoading] = useState(false);
38+
39+
// Row Editing State
40+
const [newRow, setNewRow] = useState(null);
41+
const [isSavingNewRow, setIsSavingNewRow] = useState(false);
42+
43+
// Batch Operations State
44+
const [batchMode, setBatchMode] = useState(false);
45+
const [selectedRows, setSelectedRows] = useState([]);
46+
const [batchDialog, setBatchDialog] = useState({ open: false, operation: null });
47+
const [batchResult, setBatchResult] = useState({ open: false, operation: null, result: null });
48+
const [batchLoading, setBatchLoading] = useState(false);
49+
50+
// Notifications
51+
const [notification, setNotification] = useState({ open: false, message: '', severity: 'success' });
52+
53+
// API Hook
54+
const {
55+
fetchRows: originalFetchRows,
56+
handleSave,
57+
handleExportTxt,
58+
clearDb,
59+
handleDelete: deleteRowApi,
60+
handleBatchDelete,
61+
handleBatchAddCategory,
62+
handleBatchRemoveCategory,
63+
invalidateCategoriesCache
64+
} = useAdminApi({
65+
token,
66+
setRows,
67+
setTotalRows,
68+
setDashboard,
69+
setError,
70+
setToken,
71+
setIsLogged
72+
});
73+
74+
// Wrap fetchRows to handle loading state
75+
const fetchRows = useCallback((...args) => {
76+
setLoading(true);
77+
originalFetchRows(...args);
78+
}, [originalFetchRows]);
79+
80+
// Clear loading state when rows change
81+
useEffect(() => {
82+
if (loading) {
83+
const timer = setTimeout(() => setLoading(false), 100);
84+
return () => clearTimeout(timer);
85+
}
86+
}, [rows, loading]);
87+
88+
// Initial Fetch & Updates
89+
useEffect(() => {
90+
if (selectedLanguageSetId) {
91+
fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
92+
}
93+
}, [offset, limit, filterCategory, searchTerm, selectedLanguageSetId, fetchRows]);
94+
95+
// Search Handler
96+
const handleSearchChange = useCallback((value) => {
97+
setSearchTerm(value);
98+
setOffset(0);
99+
}, []);
100+
101+
// Pagination Handlers
102+
const handleOffsetInput = (e) => {
103+
let val = parseInt(e.target.value, 10);
104+
if (isNaN(val) || val < 0) val = 0;
105+
if (val > Math.max(totalRows - limit, 0)) val = Math.max(totalRows - limit, 0);
106+
setOffsetInput(val);
107+
};
108+
109+
const goToOffset = () => {
110+
const val = parseInt(offsetInput, 10);
111+
if (!isNaN(val) && val >= 0 && val <= Math.max(totalRows - limit, 0)) {
112+
setOffset(val);
113+
}
114+
};
115+
116+
// Row Operations
117+
const handleStartAddRow = useCallback(() => {
118+
if (!selectedLanguageSetId || newRow) return;
119+
setNewRow({ categories: '', phrase: '', translation: '' });
120+
setError('');
121+
}, [selectedLanguageSetId, newRow]);
122+
123+
const handleNewRowFieldChange = useCallback((field, value) => {
124+
setNewRow(prev => (prev ? { ...prev, [field]: value } : prev));
125+
}, []);
126+
127+
const handleCancelNewRow = useCallback(() => {
128+
setNewRow(null);
129+
}, []);
130+
131+
132+
133+
const handleConfirmNewRow = useCallback(async () => {
134+
if (!newRow || !selectedLanguageSetId) return;
135+
136+
const payload = {
137+
categories: newRow.categories?.trim() || '',
138+
phrase: newRow.phrase?.trim() || '',
139+
translation: newRow.translation?.trim() || ''
140+
};
141+
142+
if (!payload.categories || !payload.phrase || !payload.translation) return;
143+
144+
const refreshRows = () => fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
145+
146+
try {
147+
setIsSavingNewRow(true);
148+
setError('');
149+
await handleSave(payload, refreshRows, () => {
150+
setNewRow(null);
151+
invalidateCategoriesCache();
152+
setNotification({
153+
open: true,
154+
message: t('row_added_successfully', 'Row added successfully'),
155+
severity: 'success'
156+
});
157+
}, selectedLanguageSetId);
158+
} catch (err) {
159+
setError(err.message);
160+
setNotification({
161+
open: true,
162+
message: err.message,
163+
severity: 'error'
164+
});
165+
} finally {
166+
setIsSavingNewRow(false);
167+
}
168+
}, [newRow, selectedLanguageSetId, fetchRows, offset, limit, filterCategory, searchTerm, handleSave, invalidateCategoriesCache, t]);
169+
170+
const handleInlineSave = useCallback((updatedRow) => {
171+
// Optimistically update
172+
setRows(prevRows => prevRows.map(row => row.id === updatedRow.id ? { ...row, ...updatedRow } : row));
173+
174+
// Let handleSave manage the API call
175+
handleSave(updatedRow, null, null, selectedLanguageSetId).catch(err => {
176+
console.error('Save failed:', err);
177+
fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
178+
});
179+
}, [offset, limit, filterCategory, searchTerm, selectedLanguageSetId, fetchRows, handleSave]);
180+
181+
const handleInlineDelete = useCallback((id) => {
182+
if (window.confirm(t('confirm_delete_phrase'))) {
183+
setRows(prevRows => prevRows.filter(row => row.id !== id));
184+
setTotalRows(prev => prev - 1);
185+
186+
deleteRowApi(id, () => {
187+
fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
188+
}, selectedLanguageSetId);
189+
}
190+
}, [deleteRowApi, offset, limit, filterCategory, searchTerm, selectedLanguageSetId, fetchRows, t]);
191+
192+
// Batch Operations
193+
const handleBatchModeToggle = () => {
194+
if (batchMode) {
195+
setBatchMode(false);
196+
setSelectedRows([]);
197+
} else {
198+
setBatchMode(true);
199+
setSelectedRows([]);
200+
}
201+
};
202+
203+
const handleRowSelectionChange = (newSelectedRows) => {
204+
setSelectedRows(newSelectedRows);
205+
};
206+
207+
const handleBatchConfirm = async (categoryName = '') => {
208+
setBatchLoading(true);
209+
setBatchDialog({ open: false, operation: null });
210+
211+
let result;
212+
const operation = batchDialog.operation;
213+
214+
try {
215+
switch (operation) {
216+
case 'delete':
217+
result = await handleBatchDelete(selectedRows, selectedLanguageSetId);
218+
break;
219+
case 'add_category':
220+
result = await handleBatchAddCategory(selectedRows, categoryName, selectedLanguageSetId);
221+
break;
222+
case 'remove_category':
223+
result = await handleBatchRemoveCategory(selectedRows, categoryName, selectedLanguageSetId);
224+
break;
225+
default:
226+
throw new Error('Unknown batch operation');
227+
}
228+
229+
setBatchResult({ open: true, operation, result });
230+
231+
if (result.success) {
232+
setSelectedRows([]);
233+
if (selectedLanguageSetId) {
234+
fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
235+
}
236+
}
237+
} catch (error) {
238+
setBatchResult({
239+
open: true,
240+
operation,
241+
result: { success: false, error: error.message }
242+
});
243+
} finally {
244+
setBatchLoading(false);
245+
}
246+
};
247+
248+
// Ignored Categories
249+
const [showIgnoredCategories, setShowIgnoredCategories] = useState(false);
250+
251+
const toggleIgnoredCategory = async (category) => {
252+
if (!currentUser || !selectedLanguageSetId) return;
253+
254+
try {
255+
const isCurrentlyIgnored = userIgnoredCategories.includes(category);
256+
const newIgnoredCategories = isCurrentlyIgnored
257+
? userIgnoredCategories.filter(c => c !== category)
258+
: [...userIgnoredCategories, category];
259+
260+
// Update backend
261+
const response = await fetch('/api/user/ignored-categories', {
262+
method: 'PUT',
263+
headers: {
264+
'Authorization': `Bearer ${token}`,
265+
'Content-Type': 'application/json'
266+
},
267+
body: JSON.stringify({
268+
language_set_id: selectedLanguageSetId,
269+
categories: newIgnoredCategories
270+
})
271+
});
272+
273+
if (!response.ok) {
274+
// Simple auth check - simplified from AdminPanel
275+
if (response.status === 401 || response.status === 400) {
276+
setToken('');
277+
setIsLogged(false);
278+
setDashboard(true);
279+
return;
280+
}
281+
const data = await response.json();
282+
setError(data.error || t('ignored_categories_updated_error'));
283+
return;
284+
}
285+
286+
onUpdateUserIgnoredCategories(newIgnoredCategories);
287+
// Refresh data
288+
if (selectedLanguageSetId) {
289+
fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId);
290+
}
291+
} catch (err) {
292+
console.error('Failed to update ignored categories:', err);
293+
setError(t('ignored_categories_updated_error'));
294+
}
295+
};
296+
297+
// Permission checks
298+
const role = currentUser?.role;
299+
const canManageAdvanced = ['root_admin', 'administrative'].includes(role);
300+
const canAddNewRow = true; // Simplified for now, logic was implicit in AdminTable
301+
302+
return (
303+
<BrowseRecordsView
304+
rows={rows}
305+
totalRows={totalRows}
306+
loading={loading}
307+
error={error}
308+
309+
offset={offset}
310+
limit={limit}
311+
setOffset={setOffset}
312+
setLimit={setLimit}
313+
offsetInput={offsetInput}
314+
handleOffsetInput={handleOffsetInput}
315+
goToOffset={goToOffset}
316+
searchTerm={searchTerm}
317+
handleSearchChange={handleSearchChange}
318+
319+
languageSets={languageSets}
320+
selectedLanguageSetId={selectedLanguageSetId}
321+
setSelectedLanguageSetId={setSelectedLanguageSetId}
322+
categories={categories}
323+
filterCategory={filterCategory}
324+
setFilterCategory={setFilterCategory}
325+
326+
currentUser={currentUser}
327+
userIgnoredCategories={userIgnoredCategories}
328+
ignoredCategories={ignoredCategories}
329+
toggleIgnoredCategory={toggleIgnoredCategory} // Pass wrapper or direct function
330+
ignoredCategoriesCount={ignoredCategories.length + userIgnoredCategories.length}
331+
showIgnoredCategories={showIgnoredCategories} // Controlled by parent? Or local? Let's make it local or prop. AdminPanel has state.
332+
setShowIgnoredCategories={setShowIgnoredCategories}
333+
334+
batchMode={batchMode}
335+
handleBatchModeToggle={handleBatchModeToggle}
336+
selectedRows={selectedRows}
337+
handleRowSelectionChange={handleRowSelectionChange}
338+
handleBatchDeleteClick={() => setBatchDialog({ open: true, operation: 'delete' })}
339+
handleBatchAddCategoryClick={() => setBatchDialog({ open: true, operation: 'add_category' })}
340+
handleBatchRemoveCategoryClick={() => setBatchDialog({ open: true, operation: 'remove_category' })}
341+
batchLoading={batchLoading}
342+
343+
batchDialog={batchDialog}
344+
handleBatchDialogClose={() => setBatchDialog({ open: false, operation: null })}
345+
handleBatchConfirm={handleBatchConfirm}
346+
batchResult={batchResult}
347+
handleBatchResultClose={() => setBatchResult({ open: false, operation: null, result: null })}
348+
349+
handleInlineSave={handleInlineSave}
350+
handleInlineDelete={handleInlineDelete}
351+
handleStartAddRow={handleStartAddRow}
352+
newRow={newRow}
353+
handleNewRowFieldChange={handleNewRowFieldChange}
354+
handleCancelNewRow={handleCancelNewRow}
355+
handleConfirmNewRow={handleConfirmNewRow}
356+
isSavingNewRow={isSavingNewRow}
357+
canAddNewRow={canAddNewRow}
358+
359+
isControlBarCollapsed={isControlBarCollapsed}
360+
isLayoutCompact={isLayoutCompact}
361+
362+
onReloadData={() => fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId)}
363+
onDownloadPhrases={() => handleExportTxt(filterCategory)}
364+
onClearDatabase={() => clearDb(() => fetchRows(offset, limit, filterCategory, searchTerm, selectedLanguageSetId))}
365+
canManageAdvanced={canManageAdvanced}
366+
367+
notification={notification}
368+
onNotificationClose={() => setNotification({ ...notification, open: false })}
369+
/>
370+
);
371+
}

frontend/src/features/admin/components/AdminPanel/BrowseRecordsView.jsx renamed to frontend/src/features/admin/components/BrowseRecords/BrowseRecordsView.jsx

File renamed without changes.

frontend/src/features/admin/components/AdminPanel/EditPhraseDialog.jsx renamed to frontend/src/features/admin/components/BrowseRecords/EditPhraseDialog.jsx

File renamed without changes.

0 commit comments

Comments
 (0)