diff --git a/app/client/package-lock.json b/app/client/package-lock.json index 59a2399f..01488d26 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "fireshare", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fireshare", - "version": "1.8.0", + "version": "1.8.1", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/app/client/package.json b/app/client/package.json index 180c4af8..0f4c7a77 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.8.0", + "version": "1.8.1", "private": true, "dependencies": { "@emotion/react": "^11.9.0", diff --git a/app/client/src/common/utils.js b/app/client/src/common/utils.js index 6b01d2ea..dd716cd9 100644 --- a/app/client/src/common/utils.js +++ b/app/client/src/common/utils.js @@ -320,3 +320,18 @@ export const formatResolution = (width, height) => { if (shortSide >= 480) return '480p' return `${width}×${height}` } + +// The name shown for an uploader, matching what UploaderMention renders so a +// sort on the Uploader column orders by the text actually on screen. +export const uploaderLabel = (uploader) => (uploader ? uploader.name || uploader.username : '') + +// Sort key for the Uploader column. Unattributed media sorts as though it were +// greater than every name, so it clusters at the end ascending and at the top +// descending — finding it is the whole point of the column, so it has to be +// reachable by one click rather than scattered through the list. +export const uploaderSortKey = (uploader) => (uploader ? uploaderLabel(uploader).toLowerCase() : '￿') + +// The option label for the uploader pickers: "Name (@username)", or just +// "@username" when the account has no distinct display name. +export const uploaderOptionLabel = (u) => + u.name && u.name !== u.username ? `${u.name} (@${u.username})` : `@${u.username}` diff --git a/app/client/src/components/admin/ImageFileManager.js b/app/client/src/components/admin/ImageFileManager.js index d91a77f2..502ed2ab 100644 --- a/app/client/src/components/admin/ImageFileManager.js +++ b/app/client/src/components/admin/ImageFileManager.js @@ -49,7 +49,16 @@ import FolderPrivacyToggle from './FolderPrivacyToggle' import FolderLinkActions from './FolderLinkActions' import { dialogPaperSx, dialogTitleSx, inputSx, labelSx, rowBoxSx } from '../../common/modalStyles' import Api from '../../services/Api' -import { formatSize, formatTableDate, formatResolution } from '../../common/utils' +import { + formatSize, + formatTableDate, + formatResolution, + uploaderLabel, + uploaderOptionLabel, + uploaderSortKey, +} from '../../common/utils' +import UploaderMention from '../user/UploaderMention' +import UploaderPicker from '../user/UploaderPicker' function sortFiles(files, column, dir) { const sorted = [...files] @@ -63,6 +72,8 @@ function sortFiles(files, column, dir) { return sorted.sort( (a, b) => mul * ((a.size || 0) + (a.derived_size || 0) - ((b.size || 0) + (b.derived_size || 0))), ) + case 'uploader': + return sorted.sort((a, b) => mul * uploaderSortKey(a.uploader).localeCompare(uploaderSortKey(b.uploader))) case 'date': return sorted.sort((a, b) => mul * (new Date(a.created_at || 0) - new Date(b.created_at || 0))) default: @@ -96,7 +107,7 @@ const folderRowSx = { } // Columns that can be toggled visible/hidden -const TOGGLEABLE_COLUMNS = ['Total Size', 'Resolution', 'Privacy', 'Date'] +const TOGGLEABLE_COLUMNS = ['Total Size', 'Resolution', 'Privacy', 'Uploader', 'Date'] function smartClean(title) { let result = title || '' @@ -259,6 +270,17 @@ const ImageFileRow = React.memo(function ImageFileRow({ file, isSelected, onTogg )} + {/* Uploader */} + {!hiddenColumns.has('Uploader') && ( + + {file.uploader ? ( + + ) : ( + Unattributed + )} + + )} + {/* Date */} {!hiddenColumns.has('Date') && ( @@ -285,6 +307,8 @@ export default function ImageFileManager({ setAlert }) { const [search, setSearch] = useState('') const [folderFilter, setFolderFilter] = useState('__all__') const [gameFilter, setGameFilter] = useState('__all__') + // '__all__', '__none__' for unattributed, or a username. + const [uploaderFilter, setUploaderFilter] = useState('__all__') const [sortColumn, setSortColumn] = useState('date') const [sortDir, setSortDir] = useState('desc') @@ -308,7 +332,6 @@ export default function ImageFileManager({ setAlert }) { const [renameDialogOpen, setRenameDialogOpen] = useState(false) const [uploaderDialogOpen, setUploaderDialogOpen] = useState(false) const [uploaderChoice, setUploaderChoice] = useState(null) - const [uploaderOptions, setUploaderOptions] = useState([]) const [colVisAnchor, setColVisAnchor] = useState(null) // Rename form state @@ -352,6 +375,60 @@ export default function ImageFileManager({ setAlert }) { return games }, [files]) + // Derived from the files themselves rather than /api/admin/uploaders: the + // filter should only offer accounts that actually have images here, and the + // count then cannot disagree with what the table is showing. + const uniqueUploaders = useMemo(() => { + const byUsername = new Map() + for (const f of files) { + if (f.uploader?.username && !byUsername.has(f.uploader.username)) { + byUsername.set(f.uploader.username, f.uploader) + } + } + return [...byUsername.values()].sort((a, b) => uploaderSortKey(a).localeCompare(uploaderSortKey(b))) + }, [files]) + + const unattributedCount = useMemo(() => files.filter((f) => !f.uploader).length, [files]) + + const uploaderFilterOptions = useMemo( + () => [ + { value: '__all__', label: 'All Uploaders' }, + ...(unattributedCount > 0 ? [{ value: '__none__', label: 'Unattributed' }] : []), + ...uniqueUploaders.map((u) => ({ value: u.username, label: uploaderOptionLabel(u) })), + ], + [uniqueUploaders, unattributedCount], + ) + + const uploaderFilterValue = useMemo( + () => uploaderFilterOptions.find((o) => o.value === uploaderFilter) || uploaderFilterOptions[0], + [uploaderFilterOptions, uploaderFilter], + ) + + // One-click route into the job this column exists for: how much of the library + // still has no owner, and a jump straight to it. + const unattributedActive = uploaderFilter === '__none__' + const unattributedChip = + unattributedCount > 0 ? ( + + } + label={`${unattributedCount} unattributed`} + onClick={() => setUploaderFilter(unattributedActive ? '__all__' : '__none__')} + sx={{ + height: 28, + fontSize: 12, + cursor: 'pointer', + bgcolor: unattributedActive ? '#3399FF22' : '#FFFFFF0D', + color: unattributedActive ? '#66B2FF' : '#FFFFFF88', + border: '1px solid', + borderColor: unattributedActive ? '#3399FF66' : '#FFFFFF1E', + '&:hover': { bgcolor: unattributedActive ? '#3399FF30' : '#FFFFFF16' }, + }} + /> + + ) : null + const filteredFiles = useMemo(() => { let result = files @@ -363,15 +440,25 @@ export default function ImageFileManager({ setAlert }) { result = result.filter((f) => f.game === gameFilter) } + if (uploaderFilter === '__none__') { + result = result.filter((f) => !f.uploader) + } else if (uploaderFilter !== '__all__') { + result = result.filter((f) => f.uploader?.username === uploaderFilter) + } + if (search.trim()) { const q = search.trim().toLowerCase() result = result.filter( - (f) => (f.title || '').toLowerCase().includes(q) || (f.filename || '').toLowerCase().includes(q), + (f) => + (f.title || '').toLowerCase().includes(q) || + (f.filename || '').toLowerCase().includes(q) || + uploaderLabel(f.uploader).toLowerCase().includes(q) || + (f.uploader?.username || '').toLowerCase().includes(q), ) } return sortFiles(result, sortColumn, sortDir) - }, [files, folderFilter, gameFilter, search, sortColumn, sortDir]) + }, [files, folderFilter, gameFilter, uploaderFilter, search, sortColumn, sortDir]) // Group files by folder — always include every known folder, even empty ones const groupedFiles = useMemo(() => { @@ -386,7 +473,7 @@ export default function ImageFileManager({ setAlert }) { return [[folderFilter, filesByFolder.get(folderFilter) || []]] } - const includeEmpty = !search.trim() && gameFilter === '__all__' + const includeEmpty = !search.trim() && gameFilter === '__all__' && uploaderFilter === '__all__' const allFolders = includeEmpty ? [...new Set([...folders, ...filesByFolder.keys()])] : [...filesByFolder.keys()] const pairs = allFolders.map((f) => [f, filesByFolder.get(f) || []]) @@ -407,6 +494,8 @@ export default function ImageFileManager({ setAlert }) { return mul * ((a.size || 0) - (b.size || 0)) case 'total_size': return mul * ((a.size || 0) + (a.derived_size || 0) - ((b.size || 0) + (b.derived_size || 0))) + case 'uploader': + return mul * uploaderSortKey(a.uploader).localeCompare(uploaderSortKey(b.uploader)) case 'date': return mul * (new Date(a.created_at || 0) - new Date(b.created_at || 0)) default: @@ -414,7 +503,7 @@ export default function ImageFileManager({ setAlert }) { } }) return pairs - }, [filteredFiles, folders, folderFilter, sortColumn, sortDir, search, gameFilter]) + }, [filteredFiles, folders, folderFilter, sortColumn, sortDir, search, gameFilter, uploaderFilter]) const filteredIds = useMemo(() => new Set(filteredFiles.map((f) => f.image_id)), [filteredFiles]) @@ -601,14 +690,6 @@ export default function ImageFileManager({ setAlert }) { } } - useEffect(() => { - if (!uploaderDialogOpen) return - Api() - .get('/api/admin/uploaders') - .then((res) => setUploaderOptions(res.data.users || [])) - .catch(() => setUploaderOptions([])) - }, [uploaderDialogOpen]) - const handleSetUploader = async () => { const ok = await runBulkAction( '/api/admin/image-files/bulk-set-uploader', @@ -976,7 +1057,7 @@ export default function ImageFileManager({ setAlert }) { setSearch(e.target.value)} fullWidth @@ -1025,7 +1106,20 @@ export default function ImageFileManager({ setAlert }) { /> )} + {uniqueUploaders.length > 0 && ( + + setUploaderFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + components={{ SingleValue: MarqueeSingleValue, Option: MarqueeOption }} + /> + + )} + + {unattributedChip} + } @@ -1251,6 +1360,11 @@ export default function ImageFileManager({ setAlert }) { label: 'Privacy', sx: { width: 75, whiteSpace: 'nowrap' }, }, + !hiddenColumns.has('Uploader') && { + col: 'uploader', + label: 'Uploader', + sx: { width: 170, minWidth: 170, whiteSpace: 'nowrap' }, + }, !hiddenColumns.has('Date') && { col: 'date', label: 'Date', @@ -1774,29 +1888,7 @@ export default function ImageFileManager({ setAlert }) { This is how library content indexed from disk gets an owner, so it appears on their profile and comes under their edit and delete permissions. - setUploaderFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + components={{ SingleValue: MarqueeSingleValue, Option: MarqueeOption }} + /> + + )} + {unattributedChip && {unattributedChip}} {/* Row 3: utility buttons */} @@ -1474,7 +1570,7 @@ export default function VideoFileManager({ setAlert }) { setSearch(e.target.value)} InputProps={{ @@ -1530,6 +1626,21 @@ export default function VideoFileManager({ setAlert }) { )} + {uniqueUploaders.length > 0 && ( + + ({ - value: u.username, - label: u.name === u.username ? `@${u.username}` : `${u.name} (@${u.username})`, - })), - ]} - value={ - uploaderChoice === null - ? { value: null, label: 'No uploader (unattributed)' } - : uploaderOptions - .filter((u) => u.username === uploaderChoice) - .map((u) => ({ - value: u.username, - label: u.name === u.username ? `@${u.username}` : `${u.name} (@${u.username})`, - }))[0] || null - } - onChange={(opt) => setUploaderChoice(opt ? opt.value : null)} - styles={selectFolderTheme} - menuPortalTarget={document.body} - placeholder="Choose an account..." - /> +