From abf24e97981dd6fa4748e52090471e3c9b18d915 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Mon, 14 Sep 2026 14:48:05 -0600 Subject: [PATCH 1/3] fix: render the watch page again for videos without a password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Helmet block grouped the three og:video tags behind a Fragment, and react-helmet reads each child's `type` as a tag name. A Fragment's type is Symbol(react.fragment), so it threw "Cannot convert a Symbol value to a string" and, with no error boundary above it, unmounted the whole view: a blank screen for every video that is not password protected. Repeat the condition per tag instead of grouping them. The tags also carried their values in `value` rather than `content`. react-helmet only copies over the attributes a meta tag actually has, so every one of these rendered empty. Unfurls were unaffected either way — crawlers get the server-rendered /w/ page from templates/metadata.html — but the tags should still say what they claim to. Broken since dc99d48, which introduced the password gate. --- app/client/src/views/Watch.js | 48 +++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/app/client/src/views/Watch.js b/app/client/src/views/Watch.js index 33db5fb0..4d11f97e 100644 --- a/app/client/src/views/Watch.js +++ b/app/client/src/views/Watch.js @@ -206,29 +206,39 @@ 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 ? ( From 3404e9134edf077489713c5b4b1c60ad607d77e6 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Mon, 14 Sep 2026 14:48:26 -0600 Subject: [PATCH 2/3] feat: surface the uploader outside the main grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attribution was only visible on the feed cards, so checking or changing who owns a clip meant going back to the grid and searching for it by name. The File Manager could set an uploader but never showed one. File Manager - /api/admin/files and /api/admin/image-files now include the uploader, in the same shape the feeds use. Video.uploader is lazy="joined", so it costs no extra query. - Sortable Uploader column on both tabs. Unattributed media sorts as greater than every name, so it gathers at the end ascending and at the top descending — finding it is the point, so one click has to reach it. - Uploader filter, a chip showing how much of the library is still unattributed that jumps straight to it, and search over uploader name and username. Both are derived from the loaded rows rather than a second request, so the count cannot disagree with the table. Clip views - The byline now appears on the watch page, the video modal and the image view. The details payload already carried it. Editing - Uploader field on the video and image edit modals, for administrators only. It writes through the existing bulk-set-uploader route rather than the details PUT, whose whitelist was deliberately narrowed and which a non-admin owner can reach: reassigning ownership is an administrator's call, not part of editing a caption. The field hides itself on the 403 from /api/admin/uploaders, so nothing offers a control whose save would fail. - Shared UploaderPicker replaces the picker that had been copied into both File Manager dialogs. Share cards - /w/ and /i/ lead their og:description with the uploader and set author and profile:username. An unfurl in chat is often the only place the recipient looks, and descriptions get truncated there, so the byline goes first. Media with no uploader is unchanged throughout: no byline, no author tag, no separator left dangling. --- app/client/src/common/utils.js | 15 ++ .../src/components/admin/ImageFileManager.js | 172 +++++++++++++---- .../src/components/admin/VideoFileManager.js | 174 ++++++++++++++---- .../src/components/cards/CompactVideoCard.js | 2 + .../src/components/modal/EditImageModal.js | 43 +++++ .../components/modal/UpdateDetailsModal.js | 31 ++++ app/client/src/components/modal/VideoModal.js | 9 +- .../src/components/user/UploaderPicker.js | 99 ++++++++++ app/client/src/views/FolderView.js | 1 + app/client/src/views/GameVideos.js | 2 + app/client/src/views/ImageFeed.js | 1 + app/client/src/views/Profile.js | 1 + app/client/src/views/ViewImage.js | 9 +- app/client/src/views/Watch.js | 10 +- app/server/fireshare/api/admin.py | 6 + .../fireshare/templates/image_metadata.html | 17 +- app/server/fireshare/templates/metadata.html | 19 +- 17 files changed, 524 insertions(+), 87 deletions(-) create mode 100644 app/client/src/components/user/UploaderPicker.js 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..." - /> +