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 && {unattributedChip}} setSearch(e.target.value)} InputProps={{ @@ -1127,6 +1221,21 @@ 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. - ({ - 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..." - /> + setUploaderDialogOpen(false)} disabled={actionLoading} sx={{ color: '#B2BAC2' }}> diff --git a/app/client/src/components/admin/VideoFileManager.js b/app/client/src/components/admin/VideoFileManager.js index 866f3a9c..dd70d6d9 100644 --- a/app/client/src/components/admin/VideoFileManager.js +++ b/app/client/src/components/admin/VideoFileManager.js @@ -53,7 +53,17 @@ 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, formatDuration, formatResolution } from '../../common/utils' +import { + formatSize, + formatTableDate, + formatDuration, + 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] @@ -69,6 +79,8 @@ function sortFiles(files, column, dir) { ) case 'duration': return sorted.sort((a, b) => mul * ((a.duration || 0) - (b.duration || 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: @@ -109,6 +121,7 @@ const TOGGLEABLE_COLUMNS = [ 'Cropped', 'Privacy', 'Password', + 'Uploader', 'Date', 'Total Size', ] @@ -379,6 +392,17 @@ const VideoFileRow = React.memo(function VideoFileRow({ file, isSelected, onTogg )} + {/* Uploader */} + {!hiddenColumns.has('Uploader') && ( + + {file.uploader ? ( + + ) : ( + Unattributed + )} + + )} + {/* Date */} {!hiddenColumns.has('Date') && ( @@ -407,6 +431,8 @@ export default function VideoFileManager({ 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') @@ -434,7 +460,6 @@ export default function VideoFileManager({ setAlert }) { const [setPasswordDialogOpen, setSetPasswordDialogOpen] = useState(false) const [uploaderDialogOpen, setUploaderDialogOpen] = useState(false) const [uploaderChoice, setUploaderChoice] = useState(null) - const [uploaderOptions, setUploaderOptions] = useState([]) const [removePasswordDialogOpen, setRemovePasswordDialogOpen] = useState(false) const [bulkPasswordInput, setBulkPasswordInput] = useState('') const [colVisAnchor, setColVisAnchor] = useState(null) @@ -484,6 +509,60 @@ export default function VideoFileManager({ setAlert }) { return games }, [files]) + // Derived from the files themselves rather than /api/admin/uploaders: the + // filter should only offer accounts that actually have videos 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 @@ -495,15 +574,25 @@ export default function VideoFileManager({ 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(() => { @@ -518,8 +607,8 @@ export default function VideoFileManager({ setAlert }) { return [[folderFilter, filesByFolder.get(folderFilter) || []]] } - // Include empty folders only when no filters are active (search/game filter would hide them anyway) - const includeEmpty = !search.trim() && gameFilter === '__all__' + // Include empty folders only when no filters are active (search/game/uploader filters would hide them anyway) + const includeEmpty = !search.trim() && gameFilter === '__all__' && uploaderFilter === '__all__' const allFolders = includeEmpty ? [...new Set([...folders, ...filesByFolder.keys()])] : [...filesByFolder.keys()] // Build [folder, files] pairs then sort folder groups by the "best" file @@ -546,6 +635,8 @@ export default function VideoFileManager({ setAlert }) { return mul * ((a.size || 0) + (a.derived_size || 0) - ((b.size || 0) + (b.derived_size || 0))) case 'duration': return mul * ((a.duration || 0) - (b.duration || 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: @@ -553,7 +644,7 @@ export default function VideoFileManager({ 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.video_id)), [filteredFiles]) @@ -754,14 +845,6 @@ export default function VideoFileManager({ setAlert }) { if (ok) setRemoveCropDialogOpen(false) } - 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/files/bulk-set-uploader', @@ -1359,7 +1442,7 @@ export default function VideoFileManager({ setAlert }) { {/* Row 1: search full width */} setSearch(e.target.value)} fullWidth @@ -1409,7 +1492,20 @@ export default function VideoFileManager({ setAlert }) { /> )} + {uniqueUploaders.length > 0 && ( + + 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 && ( + + setUploaderFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + components={{ SingleValue: MarqueeSingleValue, Option: MarqueeOption }} + /> + + )} + + {unattributedChip} + } @@ -1693,6 +1804,11 @@ export default function VideoFileManager({ setAlert }) { label: 'Password', 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', @@ -2281,29 +2397,7 @@ export default function VideoFileManager({ 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. - ({ - 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..." - /> + setUploaderDialogOpen(false)} disabled={actionLoading} sx={{ color: '#B2BAC2' }}> diff --git a/app/client/src/components/cards/CompactVideoCard.js b/app/client/src/components/cards/CompactVideoCard.js index faaa6bc3..e7b2cc35 100644 --- a/app/client/src/components/cards/CompactVideoCard.js +++ b/app/client/src/components/cards/CompactVideoCard.js @@ -391,6 +391,7 @@ const CompactVideoCard = ({ if (update.description !== undefined) setDescription(update.description || '') if ('game' in update) setGame(update.game) if (update.tags !== undefined) setLocalTags(update.tags) + if ('uploader' in update) setIntVideo((v) => ({ ...v, uploader: update.uploader })) } } @@ -440,6 +441,7 @@ const CompactVideoCard = ({ currentRecordedAt={video.recorded_at} currentGame={game} currentHasPassword={intVideo?.info?.has_password} + currentUploader={intVideo?.uploader} alertHandler={alertHandler} /> diff --git a/app/client/src/components/modal/EditImageModal.js b/app/client/src/components/modal/EditImageModal.js index 1446ef96..2af94ca8 100644 --- a/app/client/src/components/modal/EditImageModal.js +++ b/app/client/src/components/modal/EditImageModal.js @@ -22,10 +22,12 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { CopyToClipboard } from 'react-copy-to-clipboard' import { ImageService } from '../../services' +import Api from '../../services/Api' import { getPublicImageUrl, getImageUrl } from '../../common/utils' import { labelSx, inputSx, dialogPaperSx } from '../../common/modalStyles' import GameSearch from '../game/GameSearch' import DateField from './DateField' +import { UploaderSelect, useUploaderCandidates } from '../user/UploaderPicker' const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onNext, onPrev }) => { const theme = useTheme() @@ -38,10 +40,13 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN const [panningDisabled, setPanningDisabled] = React.useState(true) const [selectedDate, setSelectedDate] = React.useState(null) const [selectedTime, setSelectedTime] = React.useState('') + const [uploader, setUploader] = React.useState(null) + const { users: uploaderCandidates, forbidden: uploaderForbidden } = useUploaderCandidates(open && authenticated) const wasOpenRef = React.useRef(false) const saveTimerRef = React.useRef(null) const latestTitleRef = React.useRef('') const latestCreatedAtRef = React.useRef(undefined) + const latestUploaderRef = React.useRef(undefined) const transformRef = React.useRef(null) const prevZoomedRef = React.useRef(false) @@ -60,8 +65,10 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN setPanningDisabled(true) setSelectedDate(null) setSelectedTime('') + setUploader(null) latestTitleRef.current = '' latestCreatedAtRef.current = undefined + latestUploaderRef.current = undefined return } const t = @@ -75,7 +82,9 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN setTitle(t) latestTitleRef.current = t latestCreatedAtRef.current = undefined + latestUploaderRef.current = undefined setPrivateView(image.info?.private || false) + setUploader(image.uploader?.username ?? null) if (image.created_at) { const d = new Date(image.created_at) const pad = (n) => n.toString().padStart(2, '0') @@ -196,6 +205,7 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN private: privateView, game: selectedGame, ...(latestCreatedAtRef.current !== undefined && { created_at: latestCreatedAtRef.current }), + ...(latestUploaderRef.current !== undefined && { uploader: latestUploaderRef.current }), }) } @@ -223,6 +233,31 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN } } + // Attribution goes through the admin bulk route rather than the image details + // PUT, which does not accept an uploader — reassigning ownership is an + // administrator's call, not part of editing a caption. + const handleUploaderChange = async (username) => { + const previous = uploader + setUploader(username) + try { + await Api().post('/api/admin/image-files/bulk-set-uploader', { + image_ids: [imageId], + username, + }) + latestUploaderRef.current = username + ? uploaderCandidates.find((u) => u.username === username) || { username } + : null + alertHandler?.({ + open: true, + type: 'success', + message: username ? `Attributed to ${username}` : 'Uploader cleared', + }) + } catch (err) { + setUploader(previous) + alertHandler?.({ open: true, type: 'error', message: 'Failed to update uploader.' }) + } + } + const handlePrivacyToggle = async () => { try { await ImageService.updatePrivacy(imageId, !privateView) @@ -606,6 +641,14 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN )} + {/* Uploader — administrators only */} + {!uploaderForbidden && ( + + Uploader + + + )} + {/* Share link */} Share Link diff --git a/app/client/src/components/modal/UpdateDetailsModal.js b/app/client/src/components/modal/UpdateDetailsModal.js index 36b412b8..e96db5ef 100644 --- a/app/client/src/components/modal/UpdateDetailsModal.js +++ b/app/client/src/components/modal/UpdateDetailsModal.js @@ -20,8 +20,10 @@ import RefreshIcon from '@mui/icons-material/Refresh' import LockIcon from '@mui/icons-material/Lock' import ContentCopyIcon from '@mui/icons-material/ContentCopy' import { VideoService, GameService, TagService } from '../../services' +import Api from '../../services/Api' import GameSearch from '../game/GameSearch' import DateField from './DateField' +import { UploaderSelect, useUploaderCandidates } from '../user/UploaderPicker' import { labelSx, inputSx, rowBoxSx, dialogPaperSx } from '../../common/modalStyles' const modalSx = { @@ -313,6 +315,7 @@ const UpdateDetailsModal = ({ currentRecordedAt, currentGame, currentHasPassword, + currentUploader, alertHandler, }) => { const [title, setTitle] = React.useState(currentTitle) @@ -325,7 +328,9 @@ const UpdateDetailsModal = ({ const [allTags, setAllTags] = React.useState([]) const [tagInput, setTagInput] = React.useState('') const [hasPassword, setHasPassword] = React.useState(currentHasPassword || false) + const [uploader, setUploader] = React.useState(currentUploader?.username ?? null) const initialTagsRef = React.useRef([]) + const { users: uploaderCandidates, forbidden: uploaderForbidden } = useUploaderCandidates(open) React.useEffect(() => { if (!open) return @@ -333,6 +338,7 @@ const UpdateDetailsModal = ({ setDescription(currentDescription) setLinkedGame(currentGame || null) setHasPassword(currentHasPassword || false) + setUploader(currentUploader?.username ?? null) setTagInput('') Promise.all([TagService.getVideoTags(videoId), TagService.getTags()]) .then(([videoTagsRes, allTagsRes]) => { @@ -396,12 +402,27 @@ const UpdateDetailsModal = ({ const finalTags = [...localTags.filter((lt) => lt.id && !toRemove.find((t) => t.id === lt.id)), ...createdTags] + // Attribution goes through the admin bulk route rather than the details + // PUT, which deliberately whitelists only title/description/private. + const uploaderChanged = !uploaderForbidden && uploader !== (currentUploader?.username ?? null) + let finalUploader = currentUploader ?? null + if (uploaderChanged) { + await Api().post('/api/admin/files/bulk-set-uploader', { + video_ids: [videoId], + username: uploader, + }) + finalUploader = uploader + ? uploaderCandidates.find((u) => u.username === uploader) || { username: uploader } + : null + } + alertHandler?.({ open: true, type: 'success', message: 'Video details updated!' }) close({ title: title || currentTitle, description: description ?? currentDescription, game: linkedGame, tags: finalTags, + ...(uploaderChanged && { uploader: finalUploader }), }) } catch (err) { alertHandler?.({ open: true, type: 'error', message: err.response?.data || 'An unknown error occurred.' }) @@ -483,6 +504,16 @@ const UpdateDetailsModal = ({ /> + {!uploaderForbidden && ( + <> + + + + + + > + )} + diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js index 0ca35fac..2167b723 100644 --- a/app/client/src/components/modal/VideoModal.js +++ b/app/client/src/components/modal/VideoModal.js @@ -46,6 +46,7 @@ import SnackbarAlert from '../alert/SnackbarAlert' import VideoJSPlayer from '../player/VideoJSPlayer' import GameSearch from '../game/GameSearch' import SuggestionCard from '../cards/SuggestionCard' +import UploaderMention from '../user/UploaderMention' import WaveformCropper from './WaveformCropper' const URL = getUrl() @@ -963,7 +964,7 @@ const VideoModal = ({ - + {(vid.view_count ?? 0).toLocaleString()} {vid.view_count === 1 ? 'view' : 'views'} @@ -979,6 +980,12 @@ const VideoModal = ({ > )} + {vid.uploader && ( + <> + | + + > + )} {selectedGame && ( { + const [users, setUsers] = useState([]) + const [status, setStatus] = useState('idle') + + useEffect(() => { + // The edit modals mount with every card in a feed, so the request has to + // wait until one is actually opened — otherwise a dashboard of 50 videos + // fires 50 admin calls, and every anonymous visitor fires one too. + if (!enabled) return undefined + let cancelled = false + setStatus('loading') + Api() + .get('/api/admin/uploaders') + .then((res) => { + if (cancelled) return + setUsers(res.data.users || []) + setStatus('ready') + }) + .catch((err) => { + if (cancelled) return + setStatus(err.response?.status === 403 ? 'forbidden' : 'error') + }) + return () => { + cancelled = true + } + }, [enabled]) + + // Nothing is offered until the list actually arrives: treating "not yet + // loaded" as permitted would flash a field that a non-admin may not have. + return { users, loading: status === 'loading', forbidden: status !== 'ready' } +} + +/** + * The select itself, over an already-loaded candidate list. + * + * `value` is a username, or null for unattributed — the same shape + * /api/admin/files/bulk-set-uploader expects, where an explicit null ("clear + * this") is meaningfully different from the key being absent. + */ +export const UploaderSelect = ({ users, value, onChange, disabled = false, menuPortalTarget = document.body }) => { + const options = useMemo(() => { + const opts = [UNATTRIBUTED, ...users.map((u) => ({ value: u.username, label: uploaderOptionLabel(u) }))] + // The candidate list holds only enabled accounts, but media can still be + // owned by a disabled one. Keep that username as its own option so the field + // reports the real owner instead of silently reading as unattributed. + if (value && !opts.some((o) => o.value === value)) { + opts.push({ value, label: `@${value} (disabled)` }) + } + return opts + }, [users, value]) + + return ( + o.value === value) || UNATTRIBUTED} + onChange={(opt) => onChange(opt ? opt.value : null)} + isDisabled={disabled} + styles={selectFolderTheme} + menuPortalTarget={menuPortalTarget} + placeholder="Choose an account..." + /> + ) +} + +/** + * Self-loading picker, for callers that are already behind an admin gate and so + * have no use for the `forbidden` case (the File Manager bulk dialogs). + */ +const UploaderPicker = ({ value, onChange, disabled = false, menuPortalTarget = document.body }) => { + const { users } = useUploaderCandidates() + return ( + + ) +} + +export default UploaderPicker diff --git a/app/client/src/views/FolderView.js b/app/client/src/views/FolderView.js index 105c3d76..07c5c77a 100644 --- a/app/client/src/views/FolderView.js +++ b/app/client/src/views/FolderView.js @@ -88,6 +88,7 @@ const FolderView = ({ authenticated, cardSize, searchText }) => { }, ...(update.game !== undefined && { game: update.game }), ...(update.created_at !== undefined && { created_at: update.created_at }), + ...(update.uploader !== undefined && { uploader: update.uploader }), } } setMedia((prev) => prev.map(updateImage)) diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js index 9f791a49..5a1e1b48 100644 --- a/app/client/src/views/GameVideos.js +++ b/app/client/src/views/GameVideos.js @@ -332,6 +332,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { }, ...(update.game !== undefined && { game: update.game }), ...(update.created_at !== undefined && { created_at: update.created_at }), + ...(update.uploader !== undefined && { uploader: update.uploader }), } }), ) @@ -347,6 +348,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { }, ...(update.game !== undefined && { game: update.game }), ...(update.created_at !== undefined && { created_at: update.created_at }), + ...(update.uploader !== undefined && { uploader: update.uploader }), } }), ) diff --git a/app/client/src/views/ImageFeed.js b/app/client/src/views/ImageFeed.js index fed746c0..0d1286a9 100644 --- a/app/client/src/views/ImageFeed.js +++ b/app/client/src/views/ImageFeed.js @@ -334,6 +334,7 @@ const ImageFeed = ({ authenticated, searchText, cardSize, selectedImageFolder, o }, ...(update.game !== undefined && { game: update.game }), ...(update.created_at !== undefined && { created_at: update.created_at }), + ...(update.uploader !== undefined && { uploader: update.uploader }), } } setImages((prev) => prev.map(updateImage)) diff --git a/app/client/src/views/Profile.js b/app/client/src/views/Profile.js index 4a771fe2..a02046b1 100644 --- a/app/client/src/views/Profile.js +++ b/app/client/src/views/Profile.js @@ -201,6 +201,7 @@ const Profile = ({ authenticated }) => { }, ...(update.game !== undefined && { game: update.game }), ...(update.created_at !== undefined && { created_at: update.created_at }), + ...(update.uploader !== undefined && { uploader: update.uploader }), }, ), ) diff --git a/app/client/src/views/ViewImage.js b/app/client/src/views/ViewImage.js index b852709c..524742f6 100644 --- a/app/client/src/views/ViewImage.js +++ b/app/client/src/views/ViewImage.js @@ -7,6 +7,7 @@ import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import SnackbarAlert from '../components/alert/SnackbarAlert' import NotFound from './NotFound' import LoadingSpinner from '../components/ui/LoadingSpinner' +import UploaderMention from '../components/user/UploaderMention' import { ImageService } from '../services' import { getPublicImageUrl, getImageUrl, copyToClipboard } from '../common/utils' @@ -206,7 +207,7 @@ const ViewImage = ({ authenticated }) => { > {title} - + {views != null && ( {(views.count ?? 0).toLocaleString()} {(views.count ?? 0) === 1 ? 'view' : 'views'} @@ -224,6 +225,12 @@ const ViewImage = ({ authenticated }) => { > )} + {details?.uploader && ( + <> + | + + > + )} diff --git a/app/client/src/views/Watch.js b/app/client/src/views/Watch.js index 33db5fb0..34d5845f 100644 --- a/app/client/src/views/Watch.js +++ b/app/client/src/views/Watch.js @@ -11,6 +11,7 @@ import NotFound from './NotFound' import { VideoService, GameService } from '../services' import { getServedBy, getUrl, getPublicWatchUrl, copyToClipboard, getVideoSources } from '../common/utils' import VideoJSPlayer from '../components/player/VideoJSPlayer' +import UploaderMention from '../components/user/UploaderMention' const URL = getUrl() const PURL = getPublicWatchUrl() @@ -206,29 +207,40 @@ const Watch = ({ authenticated }) => { setAlert({ ...alert, open })}> {alert.message} + {/* + Every child here has to be a plain element: react-helmet reads each + child's `type` as a tag name, and a Fragment's type is a Symbol, which + threw "Cannot convert a Symbol value to a string" and took the whole + page down for any video without a password. Repeat the condition per tag + rather than grouping them. + + Meta values also go in `content`, not `value` — react-helmet only carries + over the attributes a meta tag actually has, so `value` produced tags + that said nothing. Crawlers read the server-rendered /w/ page anyway + (templates/metadata.html), but these should still be right. + */} {details?.info?.title} - - - - - {details?.info?.description && } - + + + + + + {details?.info?.description && } + {!details?.info?.has_password && ( - <> - - - - > + )} - + {!details?.info?.has_password && } + {!details?.info?.has_password && } + {unlocked ? ( @@ -357,7 +369,7 @@ const Watch = ({ authenticated }) => { > {details?.info?.title || 'Untitled'} - + {views != null && ( {views.toLocaleString()} {views === 1 ? 'view' : 'views'} @@ -375,6 +387,12 @@ const Watch = ({ authenticated }) => { > )} + {details?.uploader && ( + <> + | + + > + )} diff --git a/app/server/fireshare/api/admin.py b/app/server/fireshare/api/admin.py index 9f960821..224b4a5f 100644 --- a/app/server/fireshare/api/admin.py +++ b/app/server/fireshare/api/admin.py @@ -378,6 +378,10 @@ def get_admin_files(): 'created_at': v.created_at.isoformat() if v.created_at else None, 'recorded_at': v.recorded_at.isoformat() if v.recorded_at else None, 'game': game_map.get(v.video_id), + # Same shape the feeds use, so the file manager can render the byline + # with UploaderMention. Video.uploader is lazy="joined", so this costs + # no extra query. + 'uploader': v.uploader.mention_json() if v.uploader else None, }) return jsonify({'files': files, 'folders': folders}) @@ -972,6 +976,8 @@ def get_admin_image_files(): 'available': img.available, 'created_at': img.created_at.isoformat() if img.created_at else None, 'game': game_map.get(img.image_id), + # See the matching field in get_admin_files. + 'uploader': img.uploader.mention_json() if img.uploader else None, }) return jsonify({'files': files, 'folders': folders}) diff --git a/app/server/fireshare/templates/image_metadata.html b/app/server/fireshare/templates/image_metadata.html index dc013ae4..5fbae0c4 100644 --- a/app/server/fireshare/templates/image_metadata.html +++ b/app/server/fireshare/templates/image_metadata.html @@ -11,7 +11,17 @@ - + {# See metadata.html: the unfurled share card is the only place a recipient + sees who uploaded this, since the body redirects to /image/. The game + prefix stays ahead of the byline so the card still leads with the subject. #} + {% set byline = ('Uploaded by ' ~ image.uploader.name) if image.uploader else '' %} + {% set body = image.info.description if image.info and image.info.description else '' %} + {% set summary = (byline ~ ' — ' ~ body) if (byline and body) else (byline or body or 'Self-host your media and share with unique links.') %} + + + {% if image.uploader %} + + {% endif %} @@ -20,7 +30,10 @@ - + + {% if image.uploader %} + + {% endif %} {% if image.info %} diff --git a/app/server/fireshare/templates/metadata.html b/app/server/fireshare/templates/metadata.html index bb368ee2..c1e7d999 100644 --- a/app/server/fireshare/templates/metadata.html +++ b/app/server/fireshare/templates/metadata.html @@ -13,7 +13,19 @@ - + {# Nothing in this body is ever read by a person — it redirects to /watch/ — + so the unfurled share card is the one place a recipient sees who uploaded + the clip. Falls back to the description alone when the video has no + uploader, which is the case for anonymous uploads and anything indexed + off disk before ownership existed. #} + {% set byline = ('Uploaded by ' ~ video.uploader.name) if video.uploader else '' %} + {% set body = video.info.description if video.info.description else '' %} + {% set summary = (byline ~ ' — ' ~ body) if (byline and body) else (byline or body or 'Self-host your media and share with unique links.') %} + + + {% if video.uploader %} + + {% endif %} @@ -25,7 +37,10 @@ {% endif %} - + + {% if video.uploader %} + + {% endif %} {% if not password_protected %}