From a98ce82fe019e4c4a6edc4b9ce66e3a1f24d36fa Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 7 Apr 2026 08:14:07 -0600 Subject: [PATCH 01/19] update workflow dispatch --- .github/workflows/docker-publish-main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish-main.yml b/.github/workflows/docker-publish-main.yml index a2c960d0..460ee19f 100644 --- a/.github/workflows/docker-publish-main.yml +++ b/.github/workflows/docker-publish-main.yml @@ -229,7 +229,9 @@ jobs: - name: Create manifest list and push working-directory: /tmp/digests run: | - docker buildx imagetools create -t ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} \ + docker buildx imagetools create \ + -t ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} \ + -t ${{ env.REGISTRY_IMAGE }}:latest \ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - name: Inspect image From f0e815a393fd847fc898672534294e47b5383cc7 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Thu, 9 Apr 2026 21:19:02 -0600 Subject: [PATCH 02/19] screenshot system --- .env.dev | 1 + .env.prod | 1 + app/client/src/App.js | 249 ++++++---- app/client/src/common/utils.js | 41 ++ .../src/components/cards/CompactImageCard.js | 357 ++++++++++++++ app/client/src/components/cards/ImageCards.js | 148 ++++++ .../src/components/cards/ImageUploadCard.js | 410 ++++++++++++++++ app/client/src/components/cards/UploadCard.js | 146 ++++-- .../src/components/modal/DeleteImageModal.js | 77 +++ .../src/components/modal/EditImageModal.js | 296 +++++++++++ app/client/src/components/nav/Navbar20.js | 176 +++---- .../components/utils/GlobalDragDropOverlay.js | 82 ++-- app/client/src/services/ImageService.js | 77 +++ app/client/src/services/index.js | 1 + app/client/src/views/ImageFeed.js | 159 ++++++ app/client/src/views/ViewImage.js | 294 +++++++++++ app/client/vite.config.js | 1 + app/nginx/dev.template.conf | 14 + app/nginx/prod.conf | 25 +- app/server/fireshare/__init__.py | 2 + app/server/fireshare/api/__init__.py | 2 +- app/server/fireshare/api/admin.py | 12 +- app/server/fireshare/api/image.py | 459 ++++++++++++++++++ app/server/fireshare/cli.py | 259 +++++++++- app/server/fireshare/models.py | 140 +++++- .../fireshare/templates/image_metadata.html | 41 ++ app/server/fireshare/util.py | 71 +++ app/server/requirements.txt | 1 + .../versions/5a12c7f85737_add_image_tables.py | 126 +++++ 29 files changed, 3404 insertions(+), 264 deletions(-) create mode 100644 app/client/src/components/cards/CompactImageCard.js create mode 100644 app/client/src/components/cards/ImageCards.js create mode 100644 app/client/src/components/cards/ImageUploadCard.js create mode 100644 app/client/src/components/modal/DeleteImageModal.js create mode 100644 app/client/src/components/modal/EditImageModal.js create mode 100644 app/client/src/services/ImageService.js create mode 100644 app/client/src/views/ImageFeed.js create mode 100644 app/client/src/views/ViewImage.js create mode 100644 app/server/fireshare/api/image.py create mode 100644 app/server/fireshare/templates/image_metadata.html create mode 100644 migrations/versions/5a12c7f85737_add_image_tables.py diff --git a/.env.dev b/.env.dev index d17103bf..690a34a3 100644 --- a/.env.dev +++ b/.env.dev @@ -6,6 +6,7 @@ export THUMBNAIL_VIDEO_LOCATION=50 export SECRET_KEY=dev-test-key export DATA_DIRECTORY=$(pwd)/dev_root/dev_data/ export VIDEO_DIRECTORY=$(pwd)/dev_root/dev_videos/ +export IMAGE_DIRECTORY=$(pwd)/dev_root/dev_images/ export PROCESSED_DIRECTORY=$(pwd)/dev_root/dev_processed/ export STEAMGRIDDB_API_KEY="" export ADMIN_PASSWORD=admin diff --git a/.env.prod b/.env.prod index 466efd2a..c3a4002d 100644 --- a/.env.prod +++ b/.env.prod @@ -1,6 +1,7 @@ export FLASK_APP="/app/server/fireshare:create_app()" export DATA_DIRECTORY=/data/ export VIDEO_DIRECTORY=/videos/ +export IMAGE_DIRECTORY=/images/ export PROCESSED_DIRECTORY=/processed/ export TEMPLATE_PATH=/app/server/fireshare/templates export ENVIRONMENT=production diff --git a/app/client/src/App.js b/app/client/src/App.js index d457fa15..7b9cd4ef 100644 --- a/app/client/src/App.js +++ b/app/client/src/App.js @@ -4,10 +4,12 @@ import { createTheme, ThemeProvider } from '@mui/material/styles' import { CssBaseline } from '@mui/material' import Login from './views/Login' import Watch from './views/Watch' +import ViewImage from './views/ViewImage' import Dashboard from './views/Dashboard' import NotFound from './views/NotFound' import Settings from './views/Settings' import Feed from './views/Feed' +import ImageFeed from './views/ImageFeed' import Games from './views/Games' import GameVideos from './views/GameVideos' import Tags from './views/Tags' @@ -39,116 +41,157 @@ export default function App() { - - - - - - - } - /> - - - - - - } - /> - + + + + + + + } + /> + - + + + - - } - /> - - - - - - } - /> - - - - - - } - /> - - - + } + /> + + + + - - } - /> - - - - - - } - /> - - - + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + - - } - /> - + } + /> + - + + + - - } - /> - - - + } + /> + + + + - - } - /> - - - - } - /> - + } + /> + + + + + + } + /> + + + + } + /> + diff --git a/app/client/src/common/utils.js b/app/client/src/common/utils.js index 33c8fc05..8c53daac 100644 --- a/app/client/src/common/utils.js +++ b/app/client/src/common/utils.js @@ -152,6 +152,47 @@ export const getVideoUrl = (videoId, quality, extension) => { return `${URL}/api/video?id=${extension === '.mkv' ? `${videoId}&subid=1` : videoId}` } +/** + * Gets the public share URL for an image (/i/) + * @returns {string} Base URL ending with /i/ + */ +export const getPublicImageUrl = () => { + const shareableLinkDomain = getSetting('ui_config')?.['shareable_link_domain'] + if (shareableLinkDomain) { + return `${shareableLinkDomain}/i/` + } + const portWithColon = window.location.port ? `:${window.location.port}` : '' + return isLocalhost + ? `http://${window.location.hostname}:${import.meta.env.VITE_SERVER_PORT || window.location.port}/i/` + : `${window.location.protocol}//${window.location.hostname}${portWithColon}/i/` +} + +/** + * Gets the thumbnail URL for an image + */ +export const getImageThumbnailUrl = (imageId, cacheBuster) => { + const baseUrl = getUrl() + const SERVED_BY = getServedBy() + if (SERVED_BY === 'nginx') { + const url = `${baseUrl}/_content/derived/${imageId}/thumbnail.webp` + return cacheBuster ? `${url}?v=${cacheBuster}` : url + } + const url = `${baseUrl}/api/image/thumbnail?id=${imageId}` + return cacheBuster ? `${url}&v=${cacheBuster}` : url +} + +/** + * Gets the full-quality URL for an image + */ +export const getImageUrl = (imageId) => { + const baseUrl = getUrl() + const SERVED_BY = getServedBy() + if (SERVED_BY === 'nginx') { + return `${baseUrl}/_content/derived/${imageId}/image.webp` + } + return `${baseUrl}/api/image?id=${imageId}` +} + /** * Generates video sources array for Video.js player with quality options * Defaults to original quality, with 720p and 1080p as alternatives diff --git a/app/client/src/components/cards/CompactImageCard.js b/app/client/src/components/cards/CompactImageCard.js new file mode 100644 index 00000000..6825abcf --- /dev/null +++ b/app/client/src/components/cards/CompactImageCard.js @@ -0,0 +1,357 @@ +import React from 'react' +import { Box, Typography, IconButton, Menu, MenuItem, ListItemIcon, Skeleton, Tooltip } from '@mui/material' +import LinkIcon from '@mui/icons-material/Link' +import VisibilityIcon from '@mui/icons-material/Visibility' +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import { CopyToClipboard } from 'react-copy-to-clipboard' +import { getPublicImageUrl, getImageThumbnailUrl } from '../../common/utils' +import { ImageService } from '../../services' +import DeleteImageModal from '../modal/DeleteImageModal' +import TagChip from '../misc/TagChip' + +const IMAGE_VERSION = Date.now() + +const CompactImageCard = ({ image, openImageHandler, alertHandler, authenticated, onRemoveFromView }) => { + const [hover, setHover] = React.useState(false) + const [thumbnailHover, setThumbnailHover] = React.useState(false) + const [privateView, setPrivateView] = React.useState(image.info?.private) + const [title, setTitle] = React.useState( + image.info?.title || + (image.path + ? image.path + .split('/') + .pop() + .replace(/\.[^/.]+$/, '') + : 'Untitled'), + ) + const [menuAnchorEl, setMenuAnchorEl] = React.useState(null) + const [deleteModalOpen, setDeleteModalOpen] = React.useState(false) + const [imgLoaded, setImgLoaded] = React.useState(false) + const [imgRetryKey, setImgRetryKey] = React.useState(0) + const retryTimeoutRef = React.useRef(null) + const retryCountRef = React.useRef(0) + const MAX_RETRIES = 20 + const [localTags, setLocalTags] = React.useState(image.tags || []) + + const menuOpen = Boolean(menuAnchorEl) + const PURL = getPublicImageUrl() + const thumbnailUrl = getImageThumbnailUrl(image.image_id, IMAGE_VERSION) + + const viewCount = image.view_count || 0 + + // Thumbnail retry logic (image may not be processed yet) + const handleImgError = React.useCallback(() => { + if (retryCountRef.current >= MAX_RETRIES) return + retryCountRef.current += 1 + retryTimeoutRef.current = setTimeout(() => { + setImgRetryKey((k) => k + 1) + }, 4000) + }, []) + + React.useEffect(() => { + return () => { + if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current) + } + }, []) + + const handlePrivacyChange = async (e) => { + e.stopPropagation() + try { + await ImageService.updatePrivacy(image.image_id, !privateView) + alertHandler?.({ + type: privateView ? 'info' : 'warning', + message: privateView ? 'Added to your public feed' : 'Removed from your public feed', + open: true, + }) + setPrivateView((v) => !v) + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: 'Failed to update privacy' }) + } + } + + const handleDelete = async () => { + setMenuAnchorEl(null) + setDeleteModalOpen(true) + } + + const handleDeleteClose = (result) => { + setDeleteModalOpen(false) + if (result === 'delete' && onRemoveFromView) { + onRemoveFromView(image.image_id) + } + } + + // Allow parent to update card state after modal edits + const updateFromModal = (update) => { + if (!update) return + if (update.title !== undefined) setTitle(update.title) + if (update.private !== undefined) setPrivateView(update.private) + } + + return ( + + {/* Thumbnail */} + + + openImageHandler?.(image)} + onMouseEnter={() => setThumbnailHover(true)} + onMouseLeave={() => setThumbnailHover(false)} + > + {title} setImgLoaded(true)} + onError={handleImgError} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', + opacity: imgLoaded ? 1 : 0, + transition: 'opacity 0.8s ease', + }} + /> + + {/* Views badge - bottom left, hides on hover */} + + + + {viewCount} + + + + + + {/* Copy link button - shows on hover */} + + + { + e.stopPropagation() + alertHandler?.({ type: 'info', message: 'Link copied to clipboard', open: true }) + }} + > + + + + + + {/* Visibility toggle button - shows on hover when authenticated */} + {authenticated && ( + + + {privateView ? ( + + ) : ( + + )} + + + )} + + + + {/* Info section below thumbnail */} + + {/* Game icon */} + {image.game?.icon_url && ( + + {image.game.name} { + e.currentTarget.parentElement.style.display = 'none' + }} + style={{ width: 40, height: 40, objectFit: 'contain', display: 'block' }} + /> + + )} + + {/* Text info */} + + + {title} + + + {image.game?.name && ( + + {image.game.name} + + )} + + {/* Tag chips */} + {localTags.length > 0 && ( + + {localTags.map((tag) => ( + + ))} + + )} + + + {/* 3-dot menu */} + {authenticated && ( + { + e.stopPropagation() + setMenuAnchorEl(e.currentTarget) + }} + sx={{ + alignSelf: 'flex-start', + color: menuOpen ? 'primary.main' : '#FFFFFF59', + transition: 'color 0.2s', + p: 0.5, + mt: 0.25, + }} + > + + + )} + + + {/* Context menu */} + setMenuAnchorEl(null)} + onClick={(e) => e.stopPropagation()} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + slotProps={{ + paper: { + sx: { + bgcolor: '#0b132b', + border: '1px solid #FFFFFF14', + borderRadius: '10px', + minWidth: 160, + boxShadow: '0 8px 32px #00000099', + mt: 0.5, + }, + }, + }} + > + { + setMenuAnchorEl(null) + handleDelete() + }} + sx={{ color: '#FF6B6B', '&:hover': { bgcolor: '#FF6B6B1A' } }} + > + + + + Delete + + + + + ) +} + +export default CompactImageCard diff --git a/app/client/src/components/cards/ImageCards.js b/app/client/src/components/cards/ImageCards.js new file mode 100644 index 00000000..f03a8813 --- /dev/null +++ b/app/client/src/components/cards/ImageCards.js @@ -0,0 +1,148 @@ +import React, { useCallback } from 'react' +import { motion } from 'framer-motion' +import { Box, Typography } from '@mui/material' +import SnackbarAlert from '../alert/SnackbarAlert' +import ImageIcon from '@mui/icons-material/Image' +import CompactImageCard from './CompactImageCard' + +const PAGE_SIZE = 48 + +const ImageCards = ({ images, loadingIcon = null, feedView = false, authenticated, size, onImageOpen }) => { + const [imgs, setImages] = React.useState(images || []) + const [alert, setAlert] = React.useState({ open: false }) + const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE) + const [isSingleColumn, setIsSingleColumn] = React.useState(false) + const containerRef = React.useRef() + const sentinelRef = React.useRef() + + React.useEffect(() => { + setImages(images || []) + setVisibleCount(PAGE_SIZE) + }, [images]) + + React.useEffect(() => { + if (!imgs || imgs.length === 0) { + setIsSingleColumn(false) + return + } + + const el = containerRef.current + if (!el) return + + const observer = new ResizeObserver(([entry]) => { + const width = entry?.contentRect?.width || 0 + if (!width) return + const single = width < (size || 300) * 2 + 24 + setIsSingleColumn(single) + }) + + observer.observe(el) + return () => observer.disconnect() + }, [size, imgs]) + + const memoizedHandleAlert = useCallback((a) => setAlert(a), []) + + const handleDelete = (id) => { + setImages((prev) => prev.filter((img) => img.image_id !== id)) + } + + const openImage = (img) => { + if (onImageOpen) onImageOpen(img) + } + + React.useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, imgs.length)) + } + }, + { rootMargin: '400px' }, + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, [imgs.length]) + + const EMPTY_STATE = () => ( + + {!loadingIcon && ( + <> + + + No images found + {!feedView && ( + + Upload screenshots or scan your image library + + )} + + + )} + {loadingIcon} + + ) + + return ( + + setAlert({ ...alert, open })} + > + {alert.message} + + + {imgs.length === 0 && EMPTY_STATE()} + {imgs.length > 0 && ( + <> + + {imgs.slice(0, visibleCount).map((img, index) => ( + + openImage(img)} + alertHandler={memoizedHandleAlert} + authenticated={authenticated} + onRemoveFromView={handleDelete} + /> + + ))} + +
+ + )} + + ) +} + +export default ImageCards diff --git a/app/client/src/components/cards/ImageUploadCard.js b/app/client/src/components/cards/ImageUploadCard.js new file mode 100644 index 00000000..4060dad9 --- /dev/null +++ b/app/client/src/components/cards/ImageUploadCard.js @@ -0,0 +1,410 @@ +import React from 'react' +import { + Box, + Grid, + Paper, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Autocomplete, + TextField, + CircularProgress, + InputAdornment, + LinearProgress, +} from '@mui/material' +import CloudUploadIcon from '@mui/icons-material/CloudUpload' +import ImageIcon from '@mui/icons-material/Image' +import styled from '@emotion/styled' +import { ImageService, GameService, TagService } from '../../services' +import { getSetting } from '../../common/utils' +import { dialogPaperSx, dialogTitleSx, inputSx, labelSx } from '../../common/modalStyles' + +const Input = styled('input')({ display: 'none' }) + +const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif' + +const ImageUploadCard = React.forwardRef(function ImageUploadCard( + { authenticated, handleAlert, onUploadComplete, mini }, + ref, +) { + const [dialogOpen, setDialogOpen] = React.useState(false) + const [pendingFiles, setPendingFiles] = React.useState([]) + const [allGames, setAllGames] = React.useState([]) + const [allTags, setAllTags] = React.useState([]) + const [selectedGame, setSelectedGame] = React.useState(null) + const [selectedTags, setSelectedTags] = React.useState([]) + const [gameOptions, setGameOptions] = React.useState([]) + const [gameInput, setGameInput] = React.useState('') + const [gameSearchLoading, setGameSearchLoading] = React.useState(false) + const [gameCreating, setGameCreating] = React.useState(false) + const [uploading, setUploading] = React.useState(false) + const [uploadIndex, setUploadIndex] = React.useState(0) + const [uploadProgress, setUploadProgress] = React.useState(0) + const [previews, setPreviews] = React.useState([]) + + React.useImperativeHandle(ref, () => ({ + openFiles(files) { + openDialog(Array.from(files)) + }, + })) + + const openDialog = (files) => { + if (!files || files.length === 0) return + const imageFiles = files.filter((f) => f.type.startsWith('image/')) + if (imageFiles.length === 0) return + setPendingFiles(imageFiles) + // Generate previews + const urls = imageFiles.map((f) => URL.createObjectURL(f)) + setPreviews(urls) + setSelectedGame(null) + setSelectedTags([]) + setGameInput('') + setGameOptions([]) + Promise.all([GameService.getGames(), TagService.getTags()]) + .then(([gRes, tRes]) => { + const games = gRes.data || [] + setAllGames(games) + setGameOptions(games.map((g) => ({ ...g, _source: 'db' }))) + setAllTags(tRes.data || []) + }) + .catch(() => { + setAllGames([]) + setGameOptions([]) + setAllTags([]) + }) + setDialogOpen(true) + } + + const handleFilePick = (e) => { + const files = Array.from(e.target.files || []) + openDialog(files) + e.target.value = '' + } + + // Revoke preview object URLs on close + const cleanup = () => { + previews.forEach((url) => URL.revokeObjectURL(url)) + setPreviews([]) + setPendingFiles([]) + setUploading(false) + setUploadIndex(0) + setUploadProgress(0) + } + + const handleCancel = () => { + cleanup() + setDialogOpen(false) + } + + const handleGameInputChange = async (_, value) => { + setGameInput(value) + if (!value || value.length < 2) { + setGameOptions(allGames.map((g) => ({ ...g, _source: 'db' }))) + return + } + setGameSearchLoading(true) + try { + const sgdbResults = (await GameService.searchSteamGrid(value)).data || [] + const dbMatches = allGames + .filter((g) => g.name.toLowerCase().includes(value.toLowerCase())) + .map((g) => ({ ...g, _source: 'db' })) + const existingIds = new Set(allGames.map((g) => g.steamgriddb_id).filter(Boolean)) + const newFromSgdb = sgdbResults.filter((r) => !existingIds.has(r.id)).map((r) => ({ ...r, _source: 'sgdb' })) + setGameOptions([...dbMatches, ...newFromSgdb]) + } catch { + setGameOptions(allGames.map((g) => ({ ...g, _source: 'db' }))) + } + setGameSearchLoading(false) + } + + const handleGameChange = async (_, newValue) => { + if (!newValue) { + setSelectedGame(null) + return + } + if (newValue._source === 'db') { + setSelectedGame(newValue) + return + } + setGameCreating(true) + try { + const assets = (await GameService.getGameAssets(newValue.id)).data + const gameData = { + steamgriddb_id: newValue.id, + name: newValue.name, + release_date: newValue.release_date ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] : null, + hero_url: assets.hero_url, + logo_url: assets.logo_url, + icon_url: assets.icon_url, + } + const created = (await GameService.createGame(gameData)).data + setAllGames((prev) => [...prev, created]) + setSelectedGame({ ...created, _source: 'db' }) + } catch { + setSelectedGame(null) + } + setGameCreating(false) + } + + const handleUpload = async () => { + setUploading(true) + const game_id = selectedGame?.id || null + const tag_ids = selectedTags.length + ? selectedTags + .map((t) => t.id) + .filter(Boolean) + .join(',') + : null + + for (let i = 0; i < pendingFiles.length; i++) { + setUploadIndex(i) + setUploadProgress(0) + const file = pendingFiles[i] + const formData = new FormData() + formData.append('file', file) + if (game_id) formData.append('game_id', game_id) + if (tag_ids) formData.append('tag_ids', tag_ids) + try { + const uploadFn = authenticated ? ImageService.upload : ImageService.publicUpload + await uploadFn(formData, (progress) => setUploadProgress(progress)) + } catch (err) { + handleAlert({ + type: 'error', + message: `Failed to upload ${file.name}`, + open: true, + }) + } + } + + handleAlert({ + type: 'success', + message: `${pendingFiles.length} image${pendingFiles.length > 1 ? 's' : ''} uploaded — they'll appear shortly.`, + autohideDuration: 3500, + open: true, + }) + if (onUploadComplete) onUploadComplete() + cleanup() + setDialogOpen(false) + } + + const uiConfig = getSetting('ui_config') + const canUpload = authenticated ? !!uiConfig?.show_admin_upload : !!uiConfig?.allow_public_upload + if (!canUpload) return null + + return ( + <> + {/* Sidebar upload button */} + + + + + {/* Pre-upload metadata dialog */} + + + Upload {pendingFiles.length} Image{pendingFiles.length !== 1 ? 's' : ''} + + + {/* Preview grid */} + {previews.length > 0 && ( + + {previews.map((url, i) => ( + + ))} + + )} + + {/* Upload progress */} + {uploading && ( + + + Uploading {uploadIndex + 1} of {pendingFiles.length}… + + + + )} + + {/* Game selector */} + + Game (applies to all) + o.name || ''} + groupBy={(o) => (o._source === 'db' ? 'Already in library' : 'From SteamGridDB')} + value={selectedGame} + inputValue={gameInput} + onInputChange={handleGameInputChange} + onChange={handleGameChange} + loading={gameSearchLoading} + disabled={gameCreating || uploading} + filterOptions={(x) => x} + isOptionEqualToValue={(option, value) => + option.id === value.id || (option.steamgriddb_id && option.steamgriddb_id === value.steamgriddb_id) + } + renderInput={(params) => ( + + + { + e.currentTarget.style.display = 'none' + }} + style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} + /> + + {params.InputProps.startAdornment} + + ) : ( + params.InputProps.startAdornment + ), + endAdornment: ( + <> + {(gameSearchLoading || gameCreating) && ( + + + + )} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + renderOption={(props, option) => ( + + {option.icon_url && ( + { + e.currentTarget.style.display = 'none' + }} + style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} + /> + )} + {option.name} + + )} + /> + + + {/* Tag selector */} + + Tags (applies to all) + !selectedTags.find((s) => s.id === t.id))} + getOptionLabel={(o) => (typeof o === 'string' ? o : o.name)} + value={selectedTags} + onChange={(_, values) => setSelectedTags(values.map((v) => (typeof v === 'string' ? { name: v } : v)))} + disabled={uploading} + renderInput={(params) => } + /> + + + + + + + + + ) +}) + +export default ImageUploadCard diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index 54116d95..ff5a041a 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -86,7 +86,6 @@ function LogoProgress({ progress, size = 44 }) { ) } - const UploadCard = React.forwardRef(function UploadCard( { authenticated, handleAlert, mini, onUploadComplete, onProgress, dropOnly = false }, ref, @@ -120,6 +119,7 @@ const UploadCard = React.forwardRef(function UploadCard( const [selectedFolder, setSelectedFolder] = React.useState('') // Stored metadata to attach on next upload const pendingMetadata = React.useRef({ tag_ids: null, game_id: null, folder: null }) + const imageThumbnailUrlRef = React.useRef(null) React.useImperativeHandle(ref, () => ({ openFile(file) { @@ -161,7 +161,6 @@ const UploadCard = React.forwardRef(function UploadCard( const openMetadataDialog = (file) => { setPendingFile(file) - extractThumbnail(file) setSelectedGame(null) setSelectedTags([]) setTagInput('') @@ -171,6 +170,8 @@ const UploadCard = React.forwardRef(function UploadCard( setTitleInput('') setEditingTitle(false) setTitleDraft('') + + extractThumbnail(file) const foldersFetch = authenticated ? VideoService.getUploadFolders() : uiConfig?.allow_public_folder_selection @@ -235,6 +236,10 @@ const UploadCard = React.forwardRef(function UploadCard( setPendingFile(null) setThumbnail(null) setThumbnailReady(false) + if (imageThumbnailUrlRef.current) { + URL.revokeObjectURL(imageThumbnailUrlRef.current) + imageThumbnailUrlRef.current = null + } } const handleDialogCancel = () => { @@ -250,6 +255,10 @@ const UploadCard = React.forwardRef(function UploadCard( setEditingTitle(false) setThumbnail(null) setThumbnailReady(false) + if (imageThumbnailUrlRef.current) { + URL.revokeObjectURL(imageThumbnailUrlRef.current) + imageThumbnailUrlRef.current = null + } } const handleGameInputChange = async (_, value) => { @@ -506,7 +515,10 @@ const UploadCard = React.forwardRef(function UploadCard( /> ) : ( { setTitleDraft(titleInput); setEditingTitle(true) }} + onClick={() => { + setTitleDraft(titleInput) + setEditingTitle(true) + }} sx={{ display: 'flex', alignItems: 'center', @@ -523,7 +535,7 @@ const UploadCard = React.forwardRef(function UploadCard( fontWeight: 800, fontSize: 22, lineHeight: 1.3, - color: (titleInput || filenameStem) ? 'white' : '#FFFFFF55', + color: titleInput || filenameStem ? 'white' : '#FFFFFF55', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -542,13 +554,7 @@ const UploadCard = React.forwardRef(function UploadCard( if (dropOnly) { return ( - + @@ -572,7 +578,18 @@ const UploadCard = React.forwardRef(function UploadCard( }} > {thumbnailReady && thumbnail && ( - + )} {!thumbnailReady && ( { e.currentTarget.style.display = 'none' }} style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} />{params.InputProps.startAdornment} - : params.InputProps.startAdornment, - endAdornment: <>{(gameSearchLoading || gameCreating) && }{params.InputProps.endAdornment}, + startAdornment: selectedGame?.icon_url ? ( + <> + + { + e.currentTarget.style.display = 'none' + }} + style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} + /> + + {params.InputProps.startAdornment} + + ) : ( + params.InputProps.startAdornment + ), + endAdornment: ( + <> + {(gameSearchLoading || gameCreating) && ( + + + + )} + {params.InputProps.endAdornment} + + ), }} /> )} @@ -666,7 +706,14 @@ const UploadCard = React.forwardRef(function UploadCard( /> {selectedGame && ( setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} + control={ + setUploadToGameFolder(e.target.checked)} + size="small" + sx={checkboxSx} + /> + } label={Auto-sort into game folder} sx={{ mt: 0.5, ml: 0 }} /> @@ -677,7 +724,7 @@ const UploadCard = React.forwardRef(function UploadCard( Upload Folder setSelectedFolder(value || '')} disableClearable={uploadToGameFolder ? true : !!selectedFolder} disabled={uploadToGameFolder && !!selectedGame} @@ -874,13 +921,7 @@ const UploadCard = React.forwardRef(function UploadCard( {/* Pre-upload metadata dialog */} - + @@ -904,7 +945,18 @@ const UploadCard = React.forwardRef(function UploadCard( }} > {thumbnailReady && thumbnail && ( - + )} {!thumbnailReady && ( { e.currentTarget.style.display = 'none' }} style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} />{params.InputProps.startAdornment} - : params.InputProps.startAdornment, - endAdornment: <>{(gameSearchLoading || gameCreating) && }{params.InputProps.endAdornment}, + startAdornment: selectedGame?.icon_url ? ( + <> + + { + e.currentTarget.style.display = 'none' + }} + style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} + /> + + {params.InputProps.startAdornment} + + ) : ( + params.InputProps.startAdornment + ), + endAdornment: ( + <> + {(gameSearchLoading || gameCreating) && ( + + + + )} + {params.InputProps.endAdornment} + + ), }} /> )} @@ -998,7 +1073,14 @@ const UploadCard = React.forwardRef(function UploadCard( /> {selectedGame && ( setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} + control={ + setUploadToGameFolder(e.target.checked)} + size="small" + sx={checkboxSx} + /> + } label={Auto-sort into game folder} sx={{ mt: 0.5, ml: 0 }} /> @@ -1011,7 +1093,7 @@ const UploadCard = React.forwardRef(function UploadCard( Upload Folder setSelectedFolder(value || '')} disableClearable={uploadToGameFolder ? true : !!selectedFolder} disabled={uploadToGameFolder && !!selectedGame} diff --git a/app/client/src/components/modal/DeleteImageModal.js b/app/client/src/components/modal/DeleteImageModal.js new file mode 100644 index 00000000..82c5db6d --- /dev/null +++ b/app/client/src/components/modal/DeleteImageModal.js @@ -0,0 +1,77 @@ +import React from 'react' +import { Modal, Box, Typography, Button, Stack } from '@mui/material' +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import { ImageService } from '../../services' + +const DeleteImageModal = ({ open, onClose, imageId, alertHandler }) => { + const [loading, setLoading] = React.useState(false) + + const handleDelete = async () => { + setLoading(true) + try { + await ImageService.delete(imageId) + alertHandler?.({ open: true, type: 'success', message: 'Image has been deleted.' }) + onClose('delete') + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: err.response?.data || 'An unknown error occurred.' }) + setLoading(false) + } + } + + return ( + onClose(null)}> + + + Permanently delete this image? + + + Deleting this image will also remove all related data, including thumbnails and any edits to its title. Are + you sure? + + + + + + + + ) +} + +export default DeleteImageModal diff --git a/app/client/src/components/modal/EditImageModal.js b/app/client/src/components/modal/EditImageModal.js new file mode 100644 index 00000000..2e03ceba --- /dev/null +++ b/app/client/src/components/modal/EditImageModal.js @@ -0,0 +1,296 @@ +import * as React from 'react' +import { Modal, Box, Typography, Button, Stack, TextField, IconButton, Tooltip, Divider } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import DownloadIcon from '@mui/icons-material/Download' +import VisibilityIcon from '@mui/icons-material/Visibility' +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' +import { CopyToClipboard } from 'react-copy-to-clipboard' +import { ImageService } from '../../services' +import { getPublicImageUrl, getImageUrl } from '../../common/utils' +import { labelSx, inputSx, dialogPaperSx } from '../../common/modalStyles' + +const EditImageModal = ({ open, onClose, image, alertHandler, authenticated }) => { + const [title, setTitle] = React.useState('') + const [privateView, setPrivateView] = React.useState(false) + const saveTimerRef = React.useRef(null) + const latestTitleRef = React.useRef('') + + const imageId = image?.image_id + const shareUrl = `${getPublicImageUrl()}${imageId}` + const fullImageUrl = getImageUrl(imageId) + + React.useEffect(() => { + if (!open || !image) return + const t = + image.info?.title || + (image.path + ? image.path + .split('/') + .pop() + .replace(/\.[^/.]+$/, '') + : 'Untitled') + setTitle(t) + latestTitleRef.current = t + setPrivateView(image.info?.private || false) + ImageService.addView(image.image_id).catch(() => {}) + }, [open, image]) + + // Flush any pending save on unmount / close + React.useEffect(() => { + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + } + } + }, []) + + const handleTitleChange = (e) => { + const newTitle = e.target.value + setTitle(newTitle) + latestTitleRef.current = newTitle + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(async () => { + saveTimerRef.current = null + try { + await ImageService.updateDetails(imageId, { title: latestTitleRef.current }) + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: 'Failed to save title.' }) + } + }, 1500) + } + + const handleClose = () => { + // Flush pending save immediately + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + ImageService.updateDetails(imageId, { title: latestTitleRef.current }).catch(() => {}) + } + onClose({ title: latestTitleRef.current, private: privateView }) + } + + const handlePrivacyToggle = async () => { + try { + await ImageService.updatePrivacy(imageId, !privateView) + setPrivateView((v) => !v) + alertHandler?.({ + open: true, + type: privateView ? 'info' : 'warning', + message: privateView ? 'Added to your public feed' : 'Removed from your public feed', + }) + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: 'Failed to update privacy.' }) + } + } + + const handleDownload = () => { + const a = document.createElement('a') + a.href = `/api/image/original?id=${imageId}` + a.download = '' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } + + if (!image) return null + + return ( + + + {/* Image preview */} + + {title} + + + {/* Side panel */} + + {/* Header */} + + + Viewing + + + + + + + + + + {/* Title */} + + {authenticated ? ( + <> + Title + + + ) : ( + + {title || 'Untitled'} + + )} + + + {/* Privacy */} + {authenticated && ( + + Visibility + + {privateView ? ( + + ) : ( + + )} + + {privateView ? 'Private' : 'Public'} + + + + )} + + {/* Share link */} + + Share Link + + + {shareUrl} + + alertHandler?.({ open: true, type: 'success', message: 'Link copied!' })} + > + + + + + + + + + + {/* Download */} + + + + + + ) +} + +export default EditImageModal diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js index c459e2d9..0855267a 100644 --- a/app/client/src/components/nav/Navbar20.js +++ b/app/client/src/components/nav/Navbar20.js @@ -31,6 +31,7 @@ import BugReportIcon from '@mui/icons-material/BugReport' import SportsEsportsIcon from '@mui/icons-material/SportsEsports' import LocalOfferIcon from '@mui/icons-material/LocalOffer' import FolderOpenIcon from '@mui/icons-material/FolderOpen' +import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary' import { Grid, useMediaQuery, useTheme } from '@mui/material' import { useNavigate, useLocation } from 'react-router-dom' @@ -48,7 +49,8 @@ import FolderSuggestionInline from './FolderSuggestionInline' import DiskSpaceIndicator from './DiskSpaceIndicator' import { GameService } from '../../services' import UploadCard from '../cards/UploadCard' -import { RegisterUploadCardContext } from '../utils/GlobalDragDropOverlay' +import ImageUploadCard from '../cards/ImageUploadCard' +import { RegisterUploadCardContext, RegisterImageUploadCardContext } from '../utils/GlobalDragDropOverlay' import Select from 'react-select' import selectFolderTheme from '../../common/reactSelectFolderTheme' @@ -60,6 +62,7 @@ const CARD_SIZE_MULTIPLIER = 2 const allPages = [ { title: 'My Videos', icon: , href: '/', private: true }, { title: 'Public Videos', icon: , href: '/feed', private: false }, + { title: 'Screenshots', icon: , href: '/images', private: false }, { title: 'Games', icon: , href: '/games', private: false }, { title: 'Tags', icon: , href: '/tags', private: false }, { title: 'File Manager', icon: , href: '/files', private: true, adminOnly: true }, @@ -164,6 +167,7 @@ function Navbar20({ const [alert, setAlert] = React.useState({ open: false }) const [uploadTick, setUploadTick] = React.useState(0) const registerUploadCard = React.useContext(RegisterUploadCardContext) + const registerImageUploadCard = React.useContext(RegisterImageUploadCardContext) const [folderSuggestions, setFolderSuggestions] = React.useState({}) const [currentSuggestionFolder, setCurrentSuggestionFolder] = React.useState(null) const navigate = useNavigate() @@ -500,7 +504,13 @@ function Navbar20({ mini={!effectiveOpen} onUploadComplete={() => setUploadTick((t) => t + 1)} /> - + setUploadTick((t) => t + 1)} + /> @@ -641,92 +651,94 @@ function Navbar20({ ) return ( - {page !== '/login' && page !== '/watch' && (isMobile || (page !== '/files' && page !== '/settings')) && ( - - - - - - - {/* Mobile: expanded search */} - {isMobile && mobileSearchOpen && searchable && ( - - setSearchText(value)} - autoFocus - /> - - )} - {isMobile && mobileSearchOpen && ( - { - setMobileSearchOpen(false) - setSearchText('') - setMobileSearchKey((k) => k + 1) - }} - sx={{ flexShrink: 0 }} - > - - - )} - - {/* Desktop: left spacer + centered search */} - {!isMobile && } - {searchable && !isMobile && ( - - setSearchText(value)} /> - - )} - - {/* Right controls — always in DOM so portal target stays valid */} - + + - - {searchable && isMobile && ( + + + + {/* Mobile: expanded search */} + {isMobile && mobileSearchOpen && searchable && ( + + setSearchText(value)} + autoFocus + /> + + )} + {isMobile && mobileSearchOpen && ( setMobileSearchOpen(true)} - sx={{ - borderRadius: '8px', - height: '38px', - width: '38px', - border: '1px solid #2684FF', - bgcolor: '#001E3C', - '&:hover': { bgcolor: '#FFFFFF33' }, + onClick={() => { + setMobileSearchOpen(false) + setSearchText('') + setMobileSearchKey((k) => k + 1) }} + sx={{ flexShrink: 0 }} > - + )} + + {/* Desktop: left spacer + centered search */} + {!isMobile && } + {searchable && !isMobile && ( + + setSearchText(value)} /> + + )} + + {/* Right controls — always in DOM so portal target stays valid */} + + + {searchable && isMobile && ( + setMobileSearchOpen(true)} + sx={{ + borderRadius: '8px', + height: '38px', + width: '38px', + border: '1px solid #2684FF', + bgcolor: '#001E3C', + '&:hover': { bgcolor: '#FFFFFF33' }, + }} + > + + + )} + - - - - )} + + + )} {page !== '/login' && ( - {toolbar && page !== '/watch' && (isMobile || (page !== '/files' && page !== '/settings')) && } + {toolbar && + page !== '/watch' && + (isMobile || (page !== '/files' && page !== '/settings' && page !== '/image')) && } setAlert({ ...alert, open })}> {alert.message} diff --git a/app/client/src/components/utils/GlobalDragDropOverlay.js b/app/client/src/components/utils/GlobalDragDropOverlay.js index d41b1d6b..cec6b550 100644 --- a/app/client/src/components/utils/GlobalDragDropOverlay.js +++ b/app/client/src/components/utils/GlobalDragDropOverlay.js @@ -7,10 +7,10 @@ import { AuthService } from '../../services' import { getSetting } from '../../common/utils' const DISABLED_PATHS = ['/login'] -const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov'] export const DragDropDisabledContext = React.createContext(null) export const RegisterUploadCardContext = React.createContext(null) +export const RegisterImageUploadCardContext = React.createContext(null) export const DisableDragDrop = ({ children }) => { const setDisabled = React.useContext(DragDropDisabledContext) @@ -27,6 +27,7 @@ export default function GlobalDragDropOverlay({ children }) { const [authenticated, setAuthenticated] = React.useState(false) const [uiConfig, setUiConfig] = React.useState(() => getSetting('ui_config')) const registeredCardRef = React.useRef(null) + const registeredImageCardRef = React.useRef(null) const dragCounter = React.useRef(0) const activeRef = React.useRef(false) const location = useLocation() @@ -68,11 +69,16 @@ export default function GlobalDragDropOverlay({ children }) { dragCounter.current = 0 setDragActive(false) if (!activeRef.current) return - const file = e.dataTransfer?.files?.[0] - if (!file) return - const ext = file.name.split('.').pop().toLowerCase() - if (file.type.startsWith('video/') || VIDEO_EXTENSIONS.includes(ext)) { - registeredCardRef.current?.openFile(file) + const files = Array.from(e.dataTransfer?.files || []) + if (files.length === 0) return + const imageFiles = files.filter((f) => f.type.startsWith('image/')) + const videoFiles = files.filter((f) => f.type.startsWith('video/')) + if (imageFiles.length > 0 && registeredImageCardRef.current) { + registeredImageCardRef.current.openFiles(imageFiles) + } else if (videoFiles.length > 0 && registeredCardRef.current) { + registeredCardRef.current.openFile(videoFiles[0]) + } else if (files.length > 0 && registeredCardRef.current) { + registeredCardRef.current.openFile(files[0]) } } @@ -92,43 +98,45 @@ export default function GlobalDragDropOverlay({ children }) { return ( - {children} - {dragActive && - active && - createPortal( - + + {children} + {dragActive && + active && + createPortal( - - - Drop to Upload - - Release to start uploading your video - - , - document.body, - )} + + + + Drop to Upload + + Release to start uploading + + , + document.body, + )} + ) diff --git a/app/client/src/services/ImageService.js b/app/client/src/services/ImageService.js new file mode 100644 index 00000000..9a6cb117 --- /dev/null +++ b/app/client/src/services/ImageService.js @@ -0,0 +1,77 @@ +import Api from './Api' + +const service = { + getImages(sort = 'updated_at desc') { + return Api().get('/api/images', { params: { sort } }) + }, + getPublicImages(sort = 'updated_at desc') { + return Api().get('/api/images/public', { params: { sort } }) + }, + getDetails(id) { + return Api().get(`/api/image/details/${id}`) + }, + getViews(id) { + return Api().get(`/api/image/${id}/views`) + }, + updateDetails(id, details) { + return Api().put(`/api/image/details/${id}`, { ...details }) + }, + updatePrivacy(id, value) { + return Api().put(`/api/image/details/${id}`, { private: value }) + }, + addView(id) { + return Api().post('/api/image/view', { image_id: id }) + }, + delete(id) { + return Api().delete(`/api/image/delete/${id}`) + }, + upload(formData, uploadProgress) { + return Api().post('/api/upload/image', formData, { + timeout: 999999999, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + const progress = progressEvent.loaded / progressEvent.total + uploadProgress(progress, { + loaded: progressEvent.loaded / Math.pow(10, 6), + total: progressEvent.total / Math.pow(10, 6), + }) + }, + }) + }, + publicUpload(formData, uploadProgress) { + return Api().post('/api/upload/image/public', formData, { + timeout: 999999999, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + const progress = progressEvent.loaded / progressEvent.total + uploadProgress(progress, { + loaded: progressEvent.loaded / Math.pow(10, 6), + total: progressEvent.total / Math.pow(10, 6), + }) + }, + }) + }, + linkGame(imageId, gameId) { + return Api().post(`/api/images/${imageId}/game`, { game_id: gameId }) + }, + getGame(imageId) { + return Api().get(`/api/images/${imageId}/game`) + }, + unlinkGame(imageId) { + return Api().delete(`/api/images/${imageId}/game`) + }, + addTag(imageId, tagId) { + return Api().post(`/api/images/${imageId}/tags`, { tag_id: tagId }) + }, + removeTag(imageId, tagId) { + return Api().delete(`/api/images/${imageId}/tags/${tagId}`) + }, + getThumbnailUrl(imageId) { + return `/api/image/thumbnail?id=${imageId}` + }, + getImageUrl(imageId) { + return `/api/image?id=${imageId}` + }, +} + +export default service diff --git a/app/client/src/services/index.js b/app/client/src/services/index.js index 72135ec8..f85a4f39 100644 --- a/app/client/src/services/index.js +++ b/app/client/src/services/index.js @@ -1,6 +1,7 @@ export { default as AuthService } from './AuthService' export { default as TagService } from './TagService' export { default as VideoService } from './VideoService' +export { default as ImageService } from './ImageService' export { default as ConfigService } from './ConfigService' export { default as StatsService } from './StatsService' export { default as GameService } from './GameService' diff --git a/app/client/src/views/ImageFeed.js b/app/client/src/views/ImageFeed.js new file mode 100644 index 00000000..ea455656 --- /dev/null +++ b/app/client/src/views/ImageFeed.js @@ -0,0 +1,159 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { Box, Grid } from '@mui/material' +import Select from 'react-select' +import ImageCards from '../components/cards/ImageCards' +import EditImageModal from '../components/modal/EditImageModal' +import { ImageService } from '../services' +import LoadingSpinner from '../components/misc/LoadingSpinner' +import SnackbarAlert from '../components/alert/SnackbarAlert' +import selectSortTheme from '../common/reactSelectSortTheme' +import { SORT_OPTIONS } from '../common/constants' + +const ImageFeed = ({ authenticated, searchText, cardSize }) => { + const [images, setImages] = React.useState([]) + const [filteredImages, setFilteredImages] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [search, setSearch] = React.useState(searchText) + const [alert, setAlert] = React.useState({ open: false }) + const [modalImage, setModalImage] = React.useState(null) + const [sortOrder, setSortOrder] = React.useState(SORT_OPTIONS?.[0] || { value: 'newest', label: 'Newest' }) + const [toolbarTarget, setToolbarTarget] = React.useState(null) + + React.useEffect(() => { + setToolbarTarget(document.getElementById('navbar-toolbar-extra')) + }, []) + + if (searchText !== search) { + setSearch(searchText) + const tagMatches = (searchText || '').match(/#(\w+)/g) || [] + const tagNames = tagMatches.map((t) => t.slice(1).toLowerCase()) + const textQuery = (searchText || '').replace(/#\w+/g, '').trim() + setFilteredImages( + images.filter((img) => { + const titleMatch = + !textQuery || + img.info?.title?.search(new RegExp(textQuery, 'i')) >= 0 || + (img.game?.name && img.game.name.search(new RegExp(textQuery, 'i')) >= 0) + const tagMatch = tagNames.every( + (tagName) => + img.tags && + img.tags.some( + (t) => t.name.toLowerCase() === tagName || t.name.replace(/_/g, ' ').toLowerCase() === tagName, + ), + ) + return titleMatch && tagMatch + }), + ) + } + + React.useEffect(() => { + const fetchFn = authenticated ? ImageService.getImages : ImageService.getPublicImages + fetchFn() + .then((res) => { + setImages(res.data.images) + setFilteredImages(res.data.images) + setLoading(false) + }) + .catch((err) => { + setLoading(false) + setAlert({ + open: true, + type: 'error', + message: typeof err.response?.data === 'string' ? err.response.data : 'Unknown Error', + }) + }) + }, [authenticated]) + + const sortedImages = React.useMemo(() => { + if (!filteredImages) return [] + return [...filteredImages].sort((a, b) => { + if (sortOrder.value === 'most_views') { + return (b.view_count || 0) - (a.view_count || 0) + } else if (sortOrder.value === 'least_views') { + return (a.view_count || 0) - (b.view_count || 0) + } else { + const dateA = a.updated_at ? new Date(a.updated_at) : new Date(0) + const dateB = b.updated_at ? new Date(b.updated_at) : new Date(0) + return sortOrder.value === 'newest' ? dateB - dateA : dateA - dateB + } + }) + }, [filteredImages, sortOrder]) + + const handleImageOpen = (image) => { + setModalImage(image) + } + + const handleModalClose = (update) => { + if (update) { + // Update the image in state with any changes from the modal + const updateImage = (img) => { + if (img.image_id !== modalImage.image_id) return img + return { + ...img, + info: { + ...img.info, + ...(update.title !== undefined && { title: update.title }), + ...(update.private !== undefined && { private: update.private }), + }, + } + } + setImages((prev) => prev.map(updateImage)) + setFilteredImages((prev) => prev.map(updateImage)) + } + setModalImage(null) + } + + return ( + <> + setAlert({ ...alert, open })}> + {alert.message} + + {toolbarTarget && + ReactDOM.createPortal( + + ) : ( - + { - // Cycle through folders - const idx = folders.indexOf(selectedFolder.value) - const next = folders[(idx + 1) % folders.length] - handleFolderChange({ value: next, label: next }) + const idx = (page === '/' ? folders : imageFolders).indexOf( + (page === '/' ? selectedFolder : selectedImageFolder).value, + ) + const list = page === '/' ? folders : imageFolders + const next = list[(idx + 1) % list.length] + const handler = page === '/' ? handleFolderChange : handleImageFolderChange + handler({ value: next, label: next }) }} > - {selectedFolder.label.substring(0, 3)} + {(page === '/' ? selectedFolder : selectedImageFolder).label.substring(0, 3)} )} @@ -802,6 +805,9 @@ function Navbar20({ selectedFolder: effectiveFolder, onFolderChange: handleFolderChange, onFoldersLoaded: handleFoldersLoaded, + selectedImageFolder: effectiveImageFolder, + onImageFolderChange: handleImageFolderChange, + onImageFoldersLoaded: handleImageFoldersLoaded, uploadTick, })} diff --git a/app/client/src/services/ImageService.js b/app/client/src/services/ImageService.js index 9a6cb117..37759cfa 100644 --- a/app/client/src/services/ImageService.js +++ b/app/client/src/services/ImageService.js @@ -7,6 +7,12 @@ const service = { getPublicImages(sort = 'updated_at desc') { return Api().get('/api/images/public', { params: { sort } }) }, + scan() { + return Api().get('/api/manual/scan-images') + }, + getUploadFolders() { + return Api().get('/api/upload/image/folders') + }, getDetails(id) { return Api().get(`/api/image/details/${id}`) }, diff --git a/app/client/src/views/Dashboard.js b/app/client/src/views/Dashboard.js index cf18bdec..09e25aaa 100644 --- a/app/client/src/views/Dashboard.js +++ b/app/client/src/views/Dashboard.js @@ -24,6 +24,7 @@ import CheckIcon from '@mui/icons-material/Check' import LinkIcon from '@mui/icons-material/Link' import LocalOfferIcon from '@mui/icons-material/LocalOffer' import VideoCards from '../components/cards/VideoCards' +import LoadingSpinner from '../components/misc/LoadingSpinner' import { VideoService, GameService, ReleaseService, TagService } from '../services' import Select from 'react-select' import SnackbarAlert from '../components/alert/SnackbarAlert' @@ -89,11 +90,7 @@ const Dashboard = ({ const tagMatch = tagNames.every( (tagName) => v.tags && - v.tags.some( - (t) => - t.name.toLowerCase() === tagName || - t.name.replace(/_/g, ' ').toLowerCase() === tagName, - ), + v.tags.some((t) => t.name.toLowerCase() === tagName || t.name.replace(/_/g, ' ').toLowerCase() === tagName), ) return titleMatch && tagMatch }), @@ -104,7 +101,8 @@ const Dashboard = ({ } function fetchVideos() { - VideoService.getVideos() + const fetchFn = authenticated ? VideoService.getVideos() : VideoService.getPublicVideos() + fetchFn .then((res) => { setVideos(res.data.videos) setFilteredVideos(res.data.videos) @@ -148,7 +146,8 @@ const Dashboard = ({ let attempts = 0 const interval = setInterval(() => { attempts++ - VideoService.getVideos().then((res) => { + const fetchFn = authenticated ? VideoService.getVideos() : VideoService.getPublicVideos() + fetchFn.then((res) => { const fetched = res.data.videos if (fetched.length > videoCountRef.current || attempts >= 8) { clearInterval(interval) @@ -156,7 +155,10 @@ const Dashboard = ({ setFilteredVideos(fetched) const tfolders = [] fetched.forEach((v) => { - const split = v.path.split('/').slice(0, -1).filter((f) => f !== '') + const split = v.path + .split('/') + .slice(0, -1) + .filter((f) => f !== '') if (split.length > 0 && !tfolders.includes(split[0])) tfolders.push(split[0]) }) tfolders.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1)).unshift('All Videos') @@ -317,9 +319,7 @@ const Dashboard = ({ .filter((g) => g.name.toLowerCase().includes(value.toLowerCase())) .map((g) => ({ ...g, _source: 'db' })) const existingSgdbIds = new Set(allGames.map((g) => g.steamgriddb_id).filter(Boolean)) - const newFromSgdb = sgdbResults - .filter((r) => !existingSgdbIds.has(r.id)) - .map((r) => ({ ...r, _source: 'sgdb' })) + const newFromSgdb = sgdbResults.filter((r) => !existingSgdbIds.has(r.id)).map((r) => ({ ...r, _source: 'sgdb' })) setGameOptions([...dbMatches, ...newFromSgdb]) } catch { setGameOptions(allGames.map((g) => ({ ...g, _source: 'db' }))) @@ -343,9 +343,7 @@ const Dashboard = ({ const gameData = { steamgriddb_id: newValue.id, name: newValue.name, - release_date: newValue.release_date - ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] - : null, + release_date: newValue.release_date ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] : null, hero_url: assets.hero_url, logo_url: assets.logo_url, icon_url: assets.icon_url, @@ -531,11 +529,13 @@ const Dashboard = ({ + {loading && } {!loading && ( )} renderOption={(props, option) => ( - + {option.icon_url && ( { e.currentTarget.style.display = 'none' }} + onError={(e) => { + e.currentTarget.style.display = 'none' + }} style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} /> )} {option.name} - {option._source === 'sgdb' && option.release_date && ` (${new Date(option.release_date * 1000).getFullYear()})`} + {option._source === 'sgdb' && + option.release_date && + ` (${new Date(option.release_date * 1000).getFullYear()})`} )} /> @@ -666,7 +675,13 @@ const Dashboard = ({ value.map((tag, idx) => { const { onDelete } = getTagProps({ index: idx }) return ( - + ) }) } @@ -677,7 +692,10 @@ const Dashboard = ({ onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ',') && tagInputValueBulk.trim()) { e.preventDefault() - const parts = tagInputValueBulk.split(',').map((s) => s.trim()).filter(Boolean) + const parts = tagInputValueBulk + .split(',') + .map((s) => s.trim()) + .filter(Boolean) setTagInputValueBulk('') setSelectedTagsForBulk((prev) => { const merged = [...prev] @@ -696,8 +714,15 @@ const Dashboard = ({ /> - - + diff --git a/app/client/src/views/ImageFeed.js b/app/client/src/views/ImageFeed.js index ea455656..824b23db 100644 --- a/app/client/src/views/ImageFeed.js +++ b/app/client/src/views/ImageFeed.js @@ -1,6 +1,22 @@ import React from 'react' import ReactDOM from 'react-dom' -import { Box, Grid } from '@mui/material' +import { + Box, + Grid, + Button, + ButtonGroup, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + useTheme, + useMediaQuery, +} from '@mui/material' +import EditIcon from '@mui/icons-material/Edit' +import CheckIcon from '@mui/icons-material/Check' +import DeleteIcon from '@mui/icons-material/Delete' import Select from 'react-select' import ImageCards from '../components/cards/ImageCards' import EditImageModal from '../components/modal/EditImageModal' @@ -10,7 +26,7 @@ import SnackbarAlert from '../components/alert/SnackbarAlert' import selectSortTheme from '../common/reactSelectSortTheme' import { SORT_OPTIONS } from '../common/constants' -const ImageFeed = ({ authenticated, searchText, cardSize }) => { +const ImageFeed = ({ authenticated, searchText, cardSize, selectedImageFolder, onImageFoldersLoaded }) => { const [images, setImages] = React.useState([]) const [filteredImages, setFilteredImages] = React.useState([]) const [loading, setLoading] = React.useState(true) @@ -20,6 +36,13 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { const [sortOrder, setSortOrder] = React.useState(SORT_OPTIONS?.[0] || { value: 'newest', label: 'Newest' }) const [toolbarTarget, setToolbarTarget] = React.useState(null) + // Edit mode state + const [editMode, setEditMode] = React.useState(false) + const [selectedImages, setSelectedImages] = React.useState(new Set()) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const theme = useTheme() + const isMdDown = useMediaQuery(theme.breakpoints.down('md')) + React.useEffect(() => { setToolbarTarget(document.getElementById('navbar-toolbar-extra')) }, []) @@ -47,12 +70,24 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { ) } - React.useEffect(() => { + function fetchImages() { const fetchFn = authenticated ? ImageService.getImages : ImageService.getPublicImages fetchFn() .then((res) => { setImages(res.data.images) setFilteredImages(res.data.images) + const tfolders = [] + res.data.images.forEach((img) => { + const split = img.path + .split('/') + .slice(0, -1) + .filter((f) => f !== '') + if (split.length > 0 && !tfolders.includes(split[0])) { + tfolders.push(split[0]) + } + }) + tfolders.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1)).unshift('All Images') + if (onImageFoldersLoaded) onImageFoldersLoaded(tfolders) setLoading(false) }) .catch((err) => { @@ -63,11 +98,31 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { message: typeof err.response?.data === 'string' ? err.response.data : 'Unknown Error', }) }) + } + + React.useEffect(() => { + fetchImages() + // eslint-disable-next-line }, [authenticated]) + const folder = selectedImageFolder || { value: 'All Images', label: 'All Images' } + + const displayImages = React.useMemo(() => { + if (folder.value === 'All Images') { + return filteredImages + } + return filteredImages?.filter( + (img) => + img.path + .split('/') + .slice(0, -1) + .filter((f) => f !== '')[0] === folder.value, + ) + }, [filteredImages, folder]) + const sortedImages = React.useMemo(() => { - if (!filteredImages) return [] - return [...filteredImages].sort((a, b) => { + if (!displayImages) return [] + return [...displayImages].sort((a, b) => { if (sortOrder.value === 'most_views') { return (b.view_count || 0) - (a.view_count || 0) } else if (sortOrder.value === 'least_views') { @@ -78,12 +133,72 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { return sortOrder.value === 'newest' ? dateB - dateA : dateA - dateB } }) - }, [filteredImages, sortOrder]) + }, [displayImages, sortOrder]) const handleImageOpen = (image) => { setModalImage(image) } + // Edit mode handlers + const handleEditModeToggle = () => { + setEditMode(!editMode) + if (editMode) setSelectedImages(new Set()) + } + + const handleImageSelect = (imageId) => { + const next = new Set(selectedImages) + if (next.has(imageId)) next.delete(imageId) + else next.add(imageId) + setSelectedImages(next) + } + + const allSelected = sortedImages.length > 0 && selectedImages.size === sortedImages.length + + const handleSelectAllToggle = () => { + if (allSelected) setSelectedImages(new Set()) + else setSelectedImages(new Set(sortedImages.map((img) => img.image_id))) + } + + const handleDeleteClick = () => setDeleteDialogOpen(true) + const handleDeleteCancel = () => setDeleteDialogOpen(false) + + const handleDeleteConfirm = async () => { + try { + await Promise.all(Array.from(selectedImages).map((id) => ImageService.delete(id))) + setAlert({ + open: true, + type: 'success', + message: `Deleted ${selectedImages.size} image${selectedImages.size > 1 ? 's' : ''}`, + }) + fetchImages() + setSelectedImages(new Set()) + setDeleteDialogOpen(false) + setEditMode(false) + } catch (err) { + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error deleting images', + }) + } + } + + const handleNext = React.useCallback(() => { + setModalImage((cur) => { + if (!cur) return cur + const idx = sortedImages.findIndex((img) => img.image_id === cur.image_id) + return idx >= 0 && idx < sortedImages.length - 1 ? sortedImages[idx + 1] : cur + }) + }, [sortedImages]) + + const handlePrev = React.useCallback(() => { + setModalImage((cur) => { + if (!cur) return cur + const idx = sortedImages.findIndex((img) => img.image_id === cur.image_id) + return idx > 0 ? sortedImages[idx - 1] : cur + }) + }, [sortedImages]) + const handleModalClose = (update) => { if (update) { // Update the image in state with any changes from the modal @@ -111,17 +226,54 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { {toolbarTarget && ReactDOM.createPortal( - - + + )} + {authenticated && ( + + {editMode && ( + + + + + )} + + {editMode ? : } + + + )} , toolbarTarget, )} @@ -131,6 +283,8 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { image={modalImage} alertHandler={setAlert} authenticated={authenticated} + onNext={handleNext} + onPrev={handlePrev} /> @@ -145,6 +299,9 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { feedView={true} size={cardSize} onImageOpen={handleImageOpen} + editMode={editMode} + selectedImages={selectedImages} + onImageSelect={handleImageSelect} /> )} @@ -152,6 +309,25 @@ const ImageFeed = ({ authenticated, searchText, cardSize }) => { + + {/* Delete Confirmation Dialog */} + + + Delete {selectedImages.size} Image{selectedImages.size > 1 ? 's' : ''}? + + + + Are you sure you want to delete the selected image{selectedImages.size > 1 ? 's' : ''}? This will + permanently delete the original file{selectedImages.size > 1 ? 's' : ''}. + + + + + + + ) } diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js index ace9c8fa..5649db86 100644 --- a/app/client/src/views/Settings.js +++ b/app/client/src/views/Settings.js @@ -29,12 +29,13 @@ import SportsEsportsIcon from '@mui/icons-material/SportsEsports' import CalendarMonthIcon from '@mui/icons-material/CalendarMonth' import MoreVertIcon from '@mui/icons-material/MoreVert' import FolderIcon from '@mui/icons-material/Folder' +import ImageIcon from '@mui/icons-material/Image' import CloseIcon from '@mui/icons-material/Close' import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' import PlayArrowIcon from '@mui/icons-material/PlayArrow' import StopIcon from '@mui/icons-material/Stop' -import { ConfigService, VideoService, GameService } from '../services' +import { ConfigService, VideoService, GameService, ImageService } from '../services' import { setSetting } from '../common/utils' import LightTooltip from '../components/misc/LightTooltip' import GameSearch from '../components/game/GameSearch' @@ -262,6 +263,23 @@ const Settings = () => { } } + const handleScanImages = async () => { + try { + await ImageService.scan() + setAlert({ + open: true, + type: 'info', + message: 'Image scan initiated. This could take a few minutes.', + }) + } catch (err) { + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Unknown Error', + }) + } + } + const handleScanGames = async () => { try { const response = await VideoService.scanGames() @@ -588,30 +606,30 @@ const Settings = () => { setUpdatedConfig((prev) => ({ ...prev, - ui_config: { ...prev.ui_config, show_my_videos: e.target.checked }, + ui_config: { ...prev.ui_config, show_videos: e.target.checked }, })) } /> } - label="My Videos" + label="Videos" /> setUpdatedConfig((prev) => ({ ...prev, - ui_config: { ...prev.ui_config, show_public_videos: e.target.checked }, + ui_config: { ...prev.ui_config, show_images: e.target.checked }, })) } /> } - label="Public Videos" + label="Images" /> { > Scan for New Videos + + + + + + + + + + {/* Group 2: Privacy */} + + + + + + + + + + + + {/* Group 3: Destructive */} + + + + + )} + + )} + + {/* ── Search / folder filter / game filter / utility buttons ── */} + {isMobile ? ( + + setSearch(e.target.value)} + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ ...inputSx, '& .MuiInputBase-input::placeholder': { color: '#FFFFFF55' } }} + /> + + + ({ value: g, label: g })), + ]} + value={ + gameFilter === '__all__' + ? { value: '__all__', label: 'All Games' } + : { value: gameFilter, label: gameFilter } + } + onChange={(opt) => setGameFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + /> + + )} + + + + + + + setColVisAnchor(e.currentTarget)} + sx={{ + border: '1px solid #FFFFFF33', + borderRadius: 1, + color: '#FFFFFFCC', + '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' }, + }} + > + + + + + + + + + + + ) : ( + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + ...inputSx, + flex: 1, + minWidth: 140, + height: 38, + '& .MuiInputBase-input::placeholder': { color: '#FFFFFF55' }, + }} + /> + + + ({ value: g, label: g })), + ]} + value={ + gameFilter === '__all__' + ? { value: '__all__', label: 'All Games' } + : { value: gameFilter, label: gameFilter } + } + onChange={(opt) => setGameFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + /> + + )} + + + + + + + setColVisAnchor(e.currentTarget)} + sx={{ + border: '1px solid #FFFFFF33', + borderRadius: 1, + color: '#FFFFFFCC', + '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' }, + }} + > + + + + + + + + + + + )} + + {/* ── Column visibility popover ── */} + setColVisAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { bgcolor: '#0e233a', border: '1px solid #FFFFFF18', p: 1.5, minWidth: 180 } } }} + > + + Columns + + {TOGGLEABLE_COLUMNS.map((col) => ( + toggleColumnVisibility(col)} + > + + {col} + + ))} + + + {/* ── File table ── */} + + + + + + + + {[ + { + col: 'name', + label: 'Name', + sx: { maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + }, + { col: 'size', label: 'Size', sx: { width: 110, minWidth: 110, whiteSpace: 'nowrap' } }, + !hiddenColumns.has('Resolution') && { + col: null, + label: 'Resolution', + sx: { width: 100, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Privacy') && { + col: null, + label: 'Privacy', + sx: { width: 75, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Date') && { + col: 'date', + label: 'Date', + sx: { width: 110, minWidth: 110, whiteSpace: 'nowrap' }, + }, + ] + .filter(Boolean) + .map(({ col, label, sx }) => ( + handleSort(col) : undefined} + sx={{ + ...headCellSx, + ...sx, + ...(col && { + cursor: 'pointer', + userSelect: 'none', + '&:hover': { color: '#FFFFFFCC', bgcolor: '#FFFFFF10' }, + }), + }} + > + + {label} + {col && ( + + {sortColumn === col ? (sortDir === 'asc' ? '↑' : '↓') : '↕'} + + )} + + + ))} + + + + + {filteredFiles.length === 0 ? ( + + + No images found + + + ) : ( + groupedFiles.map(([folder, groupItems]) => { + const isCollapsed = collapsedFolders.has(folder) + const folderFileIds = groupItems.map((f) => f.image_id) + const allFolderSelected = folderFileIds.length > 0 && folderFileIds.every((id) => selected.has(id)) + const someFolderSelected = folderFileIds.some((id) => selected.has(id)) && !allFolderSelected + const folderTotalSize = groupItems.reduce((sum, f) => sum + (f.size || 0), 0) + const toggleFolderSelect = () => { + setSelected((prev) => { + const next = new Set(prev) + if (allFolderSelected) { + folderFileIds.forEach((id) => next.delete(id)) + } else { + folderFileIds.forEach((id) => next.add(id)) + } + return next + }) + } + return ( + + {/* Folder header row */} + folderFileIds.length > 0 && toggleFolderCollapse(folder)} + > + { + e.stopPropagation() + if (folderFileIds.length > 0) toggleFolderSelect() + }} + > + + + + + {folderFileIds.length > 0 ? ( + + {isCollapsed ? ( + + ) : ( + + )} + + ) : ( + + )} + + + {folder || '(root)'} + + {groupItems.length > 0 && ( + + {formatSize(folderTotalSize)} · {groupItems.length} image + {groupItems.length !== 1 ? 's' : ''} + + )} + + + + + {/* File rows */} + {!isCollapsed && + groupItems.map((file) => { + const isSelected = selected.has(file.image_id) + const displayName = file.title || file.filename + return ( + { + window.open(`/i/${file.image_id}`) + }} + sx={{ + cursor: 'pointer', + bgcolor: isSelected ? '#3399FF14' : 'transparent', + '&:hover': { bgcolor: isSelected ? '#3399FF1E' : '#FFFFFF08' }, + '&.Mui-selected': { bgcolor: '#3399FF14' }, + '&.Mui-selected:hover': { bgcolor: '#3399FF1E' }, + }} + > + {/* Checkbox */} + { + e.stopPropagation() + toggleSelect(file.image_id) + }} + > + {}} + sx={{ color: '#FFFFFF44', '&.Mui-checked': { color: '#3399FF' } }} + /> + + + {/* Name */} + + + + + {displayName} + + + + { + e.stopPropagation() + window.open(`/i/${file.image_id}`, '_blank') + }} + sx={{ + color: '#FFFFFF33', + p: 0.25, + flexShrink: 0, + '&:hover': { color: '#FFFFFF99' }, + }} + > + + + + + + + {/* Size */} + + {formatSize(file.size)} + + + {/* Resolution */} + {!hiddenColumns.has('Resolution') && ( + + + + + + )} + + {/* Privacy */} + {!hiddenColumns.has('Privacy') && ( + + + + + + )} + + {/* Date */} + {!hiddenColumns.has('Date') && ( + + + {formatDate(file.created_at)} + + + )} + + ) + })} + + ) + }) + )} + +
+
+ + {/* ── Move modal ── */} + !actionLoading && setMoveModalOpen(false)}> + + + Move {selectedCount} image{selectedCount !== 1 ? 's' : ''}... + + + {uniqueCurrentFolders.size === 1 && ( + + Current location + + {`/images/${[...uniqueCurrentFolders][0]}/`} + + + )} + + + Move to folder + + + + {renameOp.value === 'find_replace' && ( + + setRenameFind(e.target.value)} + disabled={actionLoading} + InputLabelProps={{ sx: { color: '#FFFFFF66' } }} + sx={inputSx} + /> + setRenameReplace(e.target.value)} + disabled={actionLoading} + InputLabelProps={{ sx: { color: '#FFFFFF66' } }} + sx={inputSx} + /> + + )} + + {renameOp.value === 'strip_prefix' && ( + + setRenamePrefix(e.target.value)} + disabled={actionLoading} + InputLabelProps={{ sx: { color: '#FFFFFF66' } }} + sx={inputSx} + /> + + )} + + {renameOp.value === 'strip_suffix' && ( + + setRenameSuffix(e.target.value)} + disabled={actionLoading} + InputLabelProps={{ sx: { color: '#FFFFFF66' } }} + sx={inputSx} + /> + + )} + + {renameOp.value === 'smart_clean' && ( + + + Removes non-alphanumeric characters, strips standalone leading/trailing numbers, and capitalizes the + first letter of each word. + + + )} + + {renamePreviewFiles.length > 0 && ( + + + Preview + + {renamePreviewFiles.map((f) => { + const before = f.title || f.filename || '' + const after = applyRenameOperation( + before, + renameOp.value, + renameFind, + renameReplace, + renamePrefix, + renameSuffix, + ) + return ( + + + {before} + + + → {after || '(empty)'} + + + ) + })} + + )} + + + + + +
+
+ ) +} diff --git a/app/client/src/components/admin/BulkFileManager.js b/app/client/src/components/admin/VideoFileManager.js similarity index 90% rename from app/client/src/components/admin/BulkFileManager.js rename to app/client/src/components/admin/VideoFileManager.js index a0ff436a..8e1b8761 100644 --- a/app/client/src/components/admin/BulkFileManager.js +++ b/app/client/src/components/admin/VideoFileManager.js @@ -173,7 +173,7 @@ function applyRenameOperation(title, op, find, replace, prefix, suffix) { return result } -export default function BulkFileManager({ setAlert }) { +export default function VideoFileManager({ setAlert }) { const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('sm')) @@ -284,9 +284,7 @@ export default function BulkFileManager({ setAlert }) { // Include empty folders only when no filters are active (search/game filter would hide them anyway) const includeEmpty = !search.trim() && gameFilter === '__all__' - const allFolders = includeEmpty - ? [...new Set([...folders, ...filesByFolder.keys()])] - : [...filesByFolder.keys()] + const allFolders = includeEmpty ? [...new Set([...folders, ...filesByFolder.keys()])] : [...filesByFolder.keys()] // Build [folder, files] pairs then sort folder groups by the "best" file // in each group according to the active sort, so that folder order reflects @@ -608,8 +606,16 @@ export default function BulkFileManager({ setAlert }) { { setMoveTargetFolder(null); setMoveModalOpen(true) }} - sx={{ border: '1px solid #3399FF44', borderRadius: 1, color: '#7FBFFF', '&:hover': { bgcolor: '#3399FF12' } }} + onClick={() => { + setMoveTargetFolder(null) + setMoveModalOpen(true) + }} + sx={{ + border: '1px solid #3399FF44', + borderRadius: 1, + color: '#7FBFFF', + '&:hover': { bgcolor: '#3399FF12' }, + }} > @@ -619,13 +625,20 @@ export default function BulkFileManager({ setAlert }) { size="small" onClick={() => { setRenameOp({ value: 'find_replace', label: 'Find & Replace' }) - setRenameFind(selectedFiles.length === 1 ? (selectedFiles[0].title || selectedFiles[0].filename || '') : '') + setRenameFind( + selectedFiles.length === 1 ? selectedFiles[0].title || selectedFiles[0].filename || '' : '', + ) setRenameReplace('') setRenamePrefix('') setRenameSuffix('') setRenameDialogOpen(true) }} - sx={{ border: '1px solid #3399FF44', borderRadius: 1, color: '#7FBFFF', '&:hover': { bgcolor: '#3399FF12' } }} + sx={{ + border: '1px solid #3399FF44', + borderRadius: 1, + color: '#7FBFFF', + '&:hover': { bgcolor: '#3399FF12' }, + }} > @@ -634,7 +647,12 @@ export default function BulkFileManager({ setAlert }) { setRemoveTranscodesDialogOpen(true)} - sx={{ border: '1px solid #FF990044', borderRadius: 1, color: '#FFBB66', '&:hover': { bgcolor: '#FF990012' } }} + sx={{ + border: '1px solid #FF990044', + borderRadius: 1, + color: '#FFBB66', + '&:hover': { bgcolor: '#FF990012' }, + }} > @@ -643,7 +661,12 @@ export default function BulkFileManager({ setAlert }) { setRemoveCropDialogOpen(true)} - sx={{ border: '1px solid #FF990044', borderRadius: 1, color: '#FFBB66', '&:hover': { bgcolor: '#FF990012' } }} + sx={{ + border: '1px solid #FF990044', + borderRadius: 1, + color: '#FFBB66', + '&:hover': { bgcolor: '#FF990012' }, + }} > @@ -653,7 +676,12 @@ export default function BulkFileManager({ setAlert }) { size="small" onClick={() => handleSetPrivacy(false)} disabled={actionLoading} - sx={{ border: '1px solid #1DB95444', borderRadius: 1, color: '#1DB954', '&:hover': { bgcolor: '#1DB95412' } }} + sx={{ + border: '1px solid #1DB95444', + borderRadius: 1, + color: '#1DB954', + '&:hover': { bgcolor: '#1DB95412' }, + }} > @@ -663,7 +691,12 @@ export default function BulkFileManager({ setAlert }) { size="small" onClick={() => handleSetPrivacy(true)} disabled={actionLoading} - sx={{ border: '1px solid #FFFFFF33', borderRadius: 1, color: '#FFFFFFCC', '&:hover': { bgcolor: '#FFFFFF0D' } }} + sx={{ + border: '1px solid #FFFFFF33', + borderRadius: 1, + color: '#FFFFFFCC', + '&:hover': { bgcolor: '#FFFFFF0D' }, + }} > @@ -672,7 +705,12 @@ export default function BulkFileManager({ setAlert }) { setDeleteDialogOpen(true)} - sx={{ border: '1px solid #f4433644', borderRadius: 1, color: '#f44336', '&:hover': { bgcolor: '#f4433612' } }} + sx={{ + border: '1px solid #f4433644', + borderRadius: 1, + color: '#f44336', + '&:hover': { bgcolor: '#f4433612' }, + }} > @@ -688,8 +726,16 @@ export default function BulkFileManager({ setAlert }) { size="small" variant="outlined" startIcon={} - onClick={() => { setMoveTargetFolder(null); setMoveModalOpen(true) }} - sx={{ textTransform: 'none', borderColor: '#3399FF44', color: '#7FBFFF', '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' } }} + onClick={() => { + setMoveTargetFolder(null) + setMoveModalOpen(true) + }} + sx={{ + textTransform: 'none', + borderColor: '#3399FF44', + color: '#7FBFFF', + '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' }, + }} > Move @@ -701,13 +747,20 @@ export default function BulkFileManager({ setAlert }) { startIcon={} onClick={() => { setRenameOp({ value: 'find_replace', label: 'Find & Replace' }) - setRenameFind(selectedFiles.length === 1 ? (selectedFiles[0].title || selectedFiles[0].filename || '') : '') + setRenameFind( + selectedFiles.length === 1 ? selectedFiles[0].title || selectedFiles[0].filename || '' : '', + ) setRenameReplace('') setRenamePrefix('') setRenameSuffix('') setRenameDialogOpen(true) }} - sx={{ textTransform: 'none', borderColor: '#3399FF44', color: '#7FBFFF', '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' } }} + sx={{ + textTransform: 'none', + borderColor: '#3399FF44', + color: '#7FBFFF', + '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' }, + }} > Rename @@ -724,7 +777,12 @@ export default function BulkFileManager({ setAlert }) { variant="outlined" startIcon={} onClick={() => setRemoveTranscodesDialogOpen(true)} - sx={{ textTransform: 'none', borderColor: '#FF990044', color: '#FFBB66', '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' } }} + sx={{ + textTransform: 'none', + borderColor: '#FF990044', + color: '#FFBB66', + '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' }, + }} > Remove Transcodes @@ -735,7 +793,12 @@ export default function BulkFileManager({ setAlert }) { variant="outlined" startIcon={} onClick={() => setRemoveCropDialogOpen(true)} - sx={{ textTransform: 'none', borderColor: '#FF990044', color: '#FFBB66', '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' } }} + sx={{ + textTransform: 'none', + borderColor: '#FF990044', + color: '#FFBB66', + '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' }, + }} > Remove Crop @@ -753,7 +816,12 @@ export default function BulkFileManager({ setAlert }) { startIcon={} onClick={() => handleSetPrivacy(false)} disabled={actionLoading} - sx={{ textTransform: 'none', borderColor: '#1DB95444', color: '#1DB954', '&:hover': { borderColor: '#1DB954', bgcolor: '#1DB95412' } }} + sx={{ + textTransform: 'none', + borderColor: '#1DB95444', + color: '#1DB954', + '&:hover': { borderColor: '#1DB954', bgcolor: '#1DB95412' }, + }} > Set Public @@ -765,7 +833,12 @@ export default function BulkFileManager({ setAlert }) { startIcon={} onClick={() => handleSetPrivacy(true)} disabled={actionLoading} - sx={{ textTransform: 'none', borderColor: '#FFFFFF33', color: '#FFFFFFCC', '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' } }} + sx={{ + textTransform: 'none', + borderColor: '#FFFFFF33', + color: '#FFFFFFCC', + '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' }, + }} > Set Private @@ -781,7 +854,12 @@ export default function BulkFileManager({ setAlert }) { variant="outlined" startIcon={} onClick={() => setDeleteDialogOpen(true)} - sx={{ textTransform: 'none', borderColor: '#f4433644', color: '#f44336', '&:hover': { borderColor: '#f44336', bgcolor: '#f4433612' } }} + sx={{ + textTransform: 'none', + borderColor: '#f4433644', + color: '#f44336', + '&:hover': { borderColor: '#f44336', bgcolor: '#f4433612' }, + }} > Delete @@ -831,7 +909,10 @@ export default function BulkFileManager({ setAlert }) { {uniqueGames.length > 0 && ( ({ value: g, label: g }))]} + options={[ + { value: '__all__', label: 'All Games' }, + ...uniqueGames.map((g) => ({ value: g, label: g })), + ]} value={ gameFilter === '__all__' ? { value: '__all__', label: 'All Games' } @@ -946,7 +1058,10 @@ export default function BulkFileManager({ setAlert }) { size="medium" variant="outlined" startIcon={} - onClick={() => { setNewFolderName(''); setCreateFolderDialogOpen(true) }} + onClick={() => { + setNewFolderName('') + setCreateFolderDialogOpen(true) + }} sx={{ height: 38, textTransform: 'none', @@ -963,7 +1078,12 @@ export default function BulkFileManager({ setAlert }) { setColVisAnchor(e.currentTarget)} - sx={{ border: '1px solid #FFFFFF33', borderRadius: 1, color: '#FFFFFFCC', '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' } }} + sx={{ + border: '1px solid #FFFFFF33', + borderRadius: 1, + color: '#FFFFFFCC', + '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' }, + }} > @@ -973,9 +1093,18 @@ export default function BulkFileManager({ setAlert }) { - {orphanLoading ? : } + {orphanLoading ? ( + + ) : ( + + )} @@ -983,7 +1112,12 @@ export default function BulkFileManager({ setAlert }) { @@ -1842,7 +1976,20 @@ export default function BulkFileManager({ setAlert }) {