wrolpi/app/src/components/FilePreview.js

590 lines
24 KiB
JavaScript
Raw Normal View History

2023-02-15 20:58:07 -07:00
import React from "react";
2023-02-15 17:40:57 -07:00
import _ from "lodash";
import {Header as SHeader, Icon, Image,} from "semantic-ui-react";
import {TagsSelector} from "../Tags";
import {encodeMediaPath, isSupportedArchive} from "./Common";
import {getFile, tagFileGroup, untagFileGroup} from "../api";
import {ArchivePreviewContent} from "./ArchivePreview";
import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, Modal} from "./Theme";
import {toast} from "react-semantic-toasts-2";
import {useOneQuery} from "../hooks/customHooks";
import {ShareButton} from "./Share";
Add ordered collections (playlists): polymorphic items + item API (#475) * Add Playlists: ordered, mixed-type collections A playlist (Collection kind='playlist') is a manually-curated, ordered list that mixes item types. Squash of the playlists feature branch (24 commits). Data model & API: - CollectionItem is polymorphic via item_kind: 'file' (FileGroup), 'zim' (zim_id + zim_entry), or 'url' (link to anything the WROLPi browser opens, e.g. /map?lat=&lon=&z=). CHECK constraint enforces exactly one kind; per-kind unique constraints; migration a7b1c2d3e4f5. - Endpoints: create playlist; add/remove/reorder items (idempotent adds: 201 new, 200 existing); GET returns the ordered items. All mutations wrol_mode_check'd. Config (source of truth): - playlists.yaml (PlaylistsConfig) stores each playlist's metadata AND ordered items (file by relative path, zim by path+entry, url inline); round-trips a DB wipe. Supports backup import (DB_CONFIG_FILE_NAMES). - The redundant generic CollectionsConfig (collections.yaml) is deleted: channels/domains own their configs, authors/subjects are re-derived. On-disk sync (sync_playlists_directory switch): - Each playlist materializes into the configurable Playlists Directory (playlists_destination, Settings UI input, excluded from indexing) with position-prefixed entries: NNNN_<file> hard links (copy fallback across filesystems) and NNNN_<title>.html stubs for zim/url items. - Tagged playlists are namespaced like channels: <playlists>/<tag>/<name>; a tag change moves the directory and prunes the empty old tag dir. - Deletion safety: managed files are only removed when they are our marked stubs or still have another hard link (st_nlink > 1) -- never a last copy. Stray loose files at the playlists root are removed; stub URLs are scheme-allowlisted (no javascript:/data:). Frontend: - /playlists list page (CollectionTable, name filter, New Playlist modal, Tag column) between Files and Zim in the nav. - View-only detail page (Name link) and edit page (Edit button): themed table with model icons/colors + poster thumbnails, items link to their model page (video/archive/doc) or preview, up/down reorder, remove, add-URL form, single-tag selector; all editing disabled in WROL Mode. - Reusable green "Add to Playlist" button on Video/Archive/Doc detail pages, the FilePreview toolbar, Zim search results, and Map pins (added as a /map URL item with a map-marker icon). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Playlist edit page parity: rename, custom directory, tag modal move suggestion Match the ChannelEditPage/DomainEditPage UX on the playlist edit page: Back/View buttons, an edit form (name, description, directory), and the shared CollectionTagModal with the violet "Tag" button. The tag modal now suggests moving the playlist to its managed tag location (playlists/<tag>/<name>); accepting the suggestion stores directory=None so the sync keeps auto-managing the location. Tag buttons on the Channel and Domain edit pages are now violet to match. Backend: update_collection learns rename (with per-kind validation and duplicate checks) and playlist custom directories (cleanup of the abandoned directory, config round-trip, commit-before-switch to avoid the background sync racing pre-commit state). tag_collection's duplicated untag/tag branches are merged, and the per-kind config-save dispatch is consolidated into _activate_config_save(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deduplicate Collection error handling and Tag creation Delete the duplicate UnknownCollection in wrolpi/collections/errors.py and re-export the canonical one from wrolpi/errors.py, which carries status_code=NOT_FOUND (the local copy inherited 500). Replace the repeated query/raise pattern in reorganize.py with Collection.find_by_id. Add Tag.get_or_create_tag and use it for collection tagging; flush the new Tag before returning so callers can read .id immediately -- previously tagging a collection with a brand-new tag name set tag_id=None. Regression test included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:37:34 -06:00
import {AddToPlaylistButton} from "./AddToPlaylist";
import {pathDirectory} from "./FileBrowser";
import {InlineErrorBoundary} from "./ErrorBoundary";
import {useLocation} from "react-router";
// Routes where file views should not be tracked
const EXCLUDED_TRACKING_ROUTES = [
/^\/videos\/video\//,
/^\/videos\/channel\/[^/]+\/video\//,
/^\/archive\/\d+/
];
function shouldSkipTracking(pathname) {
return EXCLUDED_TRACKING_ROUTES.some(pattern => pattern.test(pathname));
}
2023-02-15 17:40:57 -07:00
function getMediaPathURL(previewFile) {
if (previewFile['primary_path']) {
return `/media/${encodeMediaPath(previewFile['primary_path'])}`;
} else {
return `/media/${encodeMediaPath(previewFile['path'])}`;
}
2023-02-15 17:40:57 -07:00
}
function getDownloadPathURL(previewFile) {
if (previewFile['primary_path']) {
return `/download/${encodeMediaPath(previewFile['primary_path'])}`;
} else {
return `/download/${encodeMediaPath(previewFile['path'])}`;
}
2023-02-15 17:40:57 -07:00
}
function getEpubViewerURL(previewFile) {
let url = previewFile['primary_path'] ?? previewFile['path'];
return `/epub/epub.html?url=/media/${encodeMediaPath(url)}`
2023-02-15 17:40:57 -07:00
}
function getIframePreviewModal(previewFile, url) {
url = url || getMediaPathURL(previewFile);
return <Modal.Content>
<div className='preview-fit'>
2023-02-15 17:40:57 -07:00
<iframe title='textModal' src={url}
style={{
height: '100%', width: '100%', border: 'none',
// Use white to avoid iframe displaying with dark-theme.
backgroundColor: '#ffffff',
2023-02-15 17:40:57 -07:00
}}/>
</div>
</Modal.Content>
2023-02-15 17:40:57 -07:00
}
function getImagePreviewModal(previewFile) {
const path = previewFile.primary_path ?? previewFile.path;
const url = getMediaPathURL(previewFile);
// Get the file name from the absolute path.
const name = path.replace(/^.*[\\\/]/, '');
2023-02-22 11:48:25 -07:00
return <React.Fragment>
<Modal.Header>
{name}
</Modal.Header>
<Modal.Content>
<div className='preview-fit'>
<a href={url}>
<Image src={url} style={{maxHeight: '100%', maxWidth: '100%', objectFit: 'contain'}}/>
</a>
</div>
</Modal.Content>
2023-02-22 11:48:25 -07:00
</React.Fragment>
2023-02-15 17:40:57 -07:00
}
function getVideoPreviewModal(previewFile) {
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
2023-02-22 11:48:25 -07:00
return <React.Fragment>
<Modal.Header>
{name}
</Modal.Header>
<Modal.Content>
2023-02-22 11:48:25 -07:00
<SHeader as='h5'>{path['path']}</SHeader>
<div className='preview-fit'>
<video controls
autoPlay={true}
id="player"
playsInline={true}
style={{maxHeight: '100%', maxWidth: '100%'}}
>
<source src={url}/>
</video>
</div>
</Modal.Content>
2023-02-22 11:48:25 -07:00
</React.Fragment>
}
function getAudioPreviewModal(previewFile) {
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
2023-02-22 17:58:36 -07:00
// Some audio files may not have the correct mimetype. Trust the suffix identifies an audio file.
const type = previewFile['mimetype'] !== 'application/octet-stream' ? previewFile['mimetype'] : 'audio/mpeg';
2023-02-22 11:48:25 -07:00
return <React.Fragment>
<Modal.Header>
{name}
</Modal.Header>
<Modal.Content>
<audio controls
autoPlay={true}
id="player"
playsInline={true}
style={{width: '90%', maxWidth: '95%'}}
>
<source src={url} type={type}/>
</audio>
</Modal.Content>
</React.Fragment>
}
2026-04-15 13:37:05 -06:00
function ThreeMFPreviewModal({previewFile}) {
const containerRef = React.useRef(null);
const [dimensions, setDimensions] = React.useState(null);
const [error, setError] = React.useState(null);
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
React.useEffect(() => {
// Imperative three.js viewer — mirrors react-stl-viewer behavior for .3mf files.
let cancelled = false;
const container = containerRef.current;
if (!container) return;
let renderer, scene, camera, controls, animationId, resizeObserver, loadedGroup;
let THREE;
(async () => {
try {
THREE = await import('three');
const {OrbitControls} = await import('three/examples/jsm/controls/OrbitControls.js');
const {ThreeMFLoader} = await import('three/examples/jsm/loaders/3MFLoader.js');
if (cancelled) return;
scene = new THREE.Scene();
// Transparent background so the modal/theme color shows through,
// matching react-stl-viewer's behavior.
const width = container.clientWidth || 1;
const height = container.clientHeight || 1;
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 10000);
camera.position.set(0, 0, 100);
renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(1, 1, 1);
scene.add(dirLight);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-1, -1, -1);
scene.add(dirLight2);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const loader = new ThreeMFLoader();
loader.load(
url,
(group) => {
if (cancelled) return;
loadedGroup = group;
// Match react-stl-viewer's violet so 3MFs without authored
// materials are visible against either theme's background.
// 3MF geometry often lacks vertex normals — compute them so
// MeshPhongMaterial can shade faces instead of rendering flat black.
group.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry && !obj.geometry.attributes.normal) {
obj.geometry.computeVertexNormals();
}
obj.material = new THREE.MeshPhongMaterial({
color: 0x7353a8,
flatShading: false,
});
}
});
scene.add(group);
// Frame the model: center it, then pull camera back along Z by bounding radius.
const box = new THREE.Box3().setFromObject(group);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
group.position.sub(center);
const boundingRadius = Math.max(size.x, size.y, size.z) / 2 || 1;
const fitDistance = boundingRadius / Math.tan((Math.PI * camera.fov) / 360);
camera.position.set(0, 0, fitDistance * 2.2);
camera.near = Math.max(0.1, fitDistance / 1000);
camera.far = fitDistance * 100;
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
setDimensions({
width: size.x,
height: size.y,
length: size.z,
boundingRadius: size.length() / 2,
});
},
undefined,
(err) => {
console.error('Failed to load 3MF', err);
if (!cancelled) setError('Failed to load 3MF file.');
},
);
const animate = () => {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
resizeObserver = new ResizeObserver(() => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
resizeObserver.observe(container);
} catch (err) {
console.error('3MF viewer init failed', err);
if (!cancelled) setError('Failed to initialize 3MF viewer.');
}
})();
return () => {
cancelled = true;
if (animationId) cancelAnimationFrame(animationId);
if (resizeObserver) resizeObserver.disconnect();
if (controls) controls.dispose();
if (loadedGroup && THREE) {
loadedGroup.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
}
});
}
if (renderer) {
renderer.dispose();
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
};
}, [url]);
return <React.Fragment>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
{dimensions && <div style={{marginBottom: '1em', textAlign: 'center'}}>
<b>Width:</b> {dimensions.width.toFixed(2)}
{' | '}
<b>Height:</b> {dimensions.height.toFixed(2)}
{' | '}
<b>Length:</b> {dimensions.length.toFixed(2)}
{' | '}
<b>Bounding Radius:</b> {dimensions.boundingRadius.toFixed(2)}
2026-04-15 13:58:50 -06:00
{' (model units)'}
2026-04-15 13:37:05 -06:00
</div>}
{error && <div style={{color: 'red', textAlign: 'center'}}>{error}</div>}
<InlineErrorBoundary>
<div className='preview-fit'>
<div ref={containerRef} style={{height: '100%', width: '100%'}}/>
</div>
</InlineErrorBoundary>
</Modal.Content>
</React.Fragment>
}
function STLPreviewModal({previewFile}) {
const [dimensions, setDimensions] = React.useState(null);
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
return <React.Fragment>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
{dimensions && <div style={{marginBottom: '1em', textAlign: 'center'}}>
<b>Width:</b> {dimensions.width.toFixed(2)}
{' | '}
<b>Height:</b> {dimensions.height.toFixed(2)}
{' | '}
<b>Length:</b> {dimensions.length.toFixed(2)}
{' | '}
<b>Bounding Radius:</b> {dimensions.boundingRadius.toFixed(2)}
</div>}
<InlineErrorBoundary>
<div className='preview-fit'>
<StlViewer orbitControls shadows
url={url}
style={{height: '100%', width: '100%'}}
modelProps={{color: '#7353a8'}}
onFinishLoading={(dims) => setDimensions(dims)}
/>
</div>
</InlineErrorBoundary>
</Modal.Content>
2023-02-22 11:48:25 -07:00
</React.Fragment>
2023-02-15 17:40:57 -07:00
}
function getGenericPreviewModal(previewFile) {
// No special handler for this file type, just open it.
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
return <React.Fragment>
<Modal.Header>{name}</Modal.Header>
</React.Fragment>
}
2023-02-15 20:58:07 -07:00
export const FilePreviewContext = React.createContext({
previewFile: null,
setPreviewFile: null,
previewModal: null,
setPreviewModal: null,
setCallbacks: null,
2023-02-15 20:58:07 -07:00
});
2023-02-15 21:20:23 -07:00
const MAXIMUM_TEXT_SIZE = 5_000_000;
export function FilePreviewProvider({children}) {
// Data about the file that is being previewed.
const [previewFile, setPreviewFile] = React.useState(null);
// A modal is only displayed once we have data about the file.
2023-02-15 20:58:07 -07:00
const [previewModal, setPreviewModal] = React.useState(null);
const [callbacks, setCallbacks] = React.useState(null);
// The file path from the `preview` URL query.
const [previewQuery, setPreviewQuery] = useOneQuery('preview');
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
const location = useLocation();
// Determine if tracking should be skipped based on current route
const skipTracking = shouldSkipTracking(location.pathname);
const handleErrorModalClose = () => {
setPreviewQuery(null);
setErrorModalOpen(false);
}
const errorModal = <Modal closeIcon
open={errorModalOpen}
onClose={handleErrorModalClose}
>
<Modal.Header>Unknown File</Modal.Header>
<Modal.Content>Cannot display preview, no such file exists: {previewQuery}</Modal.Content>
<Modal.Actions>
<Button onClick={handleErrorModalClose}>Close</Button>
</Modal.Actions>
</Modal>
React.useEffect(() => {
if (!_.isEmpty(previewFile)) {
// Change URL to match the file that is being previewed.
setPreviewQuery(previewFile['primary_path'] || previewFile['path']);
}
}, [previewFile]);
const initPreviewFile = async () => {
// Get simple information about the file, preview the file.
try {
const file = await getFile(previewQuery, skipTracking);
setPreviewFile(file);
} catch (e) {
console.error(e);
setPreviewModal(errorModal);
setErrorModalOpen(true);
}
}
React.useEffect(() => {
// Navigated to a page which has a file preview active, fetch the information about the file, then display it.
if (previewQuery && !previewFile) {
initPreviewFile();
} else if (!previewQuery && (previewFile || previewModal)) {
// User navigated back/forward and preview query is gone, close the preview.
// Only close if there's actually a preview open (previewFile or previewModal exists).
setPreviewFile(null);
setPreviewModal(null);
}
}, [previewQuery]);
2023-02-15 20:58:07 -07:00
const localFetchFile = async () => {
// Get the file again with its Tags.
const {path, primary_path} = previewFile;
try {
const file = await getFile(primary_path ?? path, skipTracking);
setPreviewFile(file);
} finally {
await handleCallbacks();
}
2023-02-15 20:58:07 -07:00
};
2023-02-15 17:40:57 -07:00
const handleClose = (e) => {
if (e) {
e.preventDefault();
}
setPreviewModal(null);
setPreviewFile(null);
setPreviewQuery(null);
}
const handleCallbacks = async () => {
for (const callbacksKey in callbacks) {
try {
await callbacks[callbacksKey]();
} catch (e) {
console.error(e);
}
}
}
const localAddTag = async (name) => {
await tagFileGroup(previewFile, name);
await localFetchFile();
}
const localRemoveTag = async (name) => {
await untagFileGroup(previewFile, name);
await localFetchFile();
}
function setModalContent(content, url, downloadURL, path, taggable = true) {
// All action buttons share `size='small'` and `icon` for consistent height across viewports.
// Close is handled by the Modal's `closeIcon` (X in the top-right corner) — no dedicated button.
const openButton = url
? <Button color='blue' as='a' href={url} size='small' icon labelPosition='left'>
<Icon name='external'/>Open
</Button>
: null;
const tagsDisplay = taggable ?
<TagsSelector selectedTagNames={previewFile['tags']} onAdd={localAddTag}
onRemove={localRemoveTag}/>
: null;
const downloadButton = downloadURL
? <Button color='yellow' as='a' href={downloadURL} size='small' icon='download'/>
: null;
const directoryURL = path ? `/files?folders=${encodeURIComponent(pathDirectory(path))}` : null;
const directoryButton = path ? <Button as='a' href={directoryURL} size='small' icon='folder'/> : null;
const pathContent = path ? <Modal.Content>
<pre>{path}</pre>
</Modal.Content>
: null;
console.log('Previewing', path);
console.log('Preview Download URL', downloadURL);
console.log('Preview Open URL', url);
// Single flex toolbar — same button order at every viewport size.
// `flex-wrap: wrap` lets the tags selector drop to a second line on narrow screens
// while the button groups stay together and in order: [file actions] [tags] [open/close].
2023-02-15 20:58:07 -07:00
setPreviewModal(<Modal closeIcon
size='fullscreen'
open={true}
onClose={e => handleClose(e)}
2023-02-15 17:40:57 -07:00
>
{pathContent}
<Modal.Actions>
<div className='preview-toolbar'>
<div className='preview-toolbar-group'>
{downloadButton}
{directoryButton}
<ShareButton/>
Add ordered collections (playlists): polymorphic items + item API (#475) * Add Playlists: ordered, mixed-type collections A playlist (Collection kind='playlist') is a manually-curated, ordered list that mixes item types. Squash of the playlists feature branch (24 commits). Data model & API: - CollectionItem is polymorphic via item_kind: 'file' (FileGroup), 'zim' (zim_id + zim_entry), or 'url' (link to anything the WROLPi browser opens, e.g. /map?lat=&lon=&z=). CHECK constraint enforces exactly one kind; per-kind unique constraints; migration a7b1c2d3e4f5. - Endpoints: create playlist; add/remove/reorder items (idempotent adds: 201 new, 200 existing); GET returns the ordered items. All mutations wrol_mode_check'd. Config (source of truth): - playlists.yaml (PlaylistsConfig) stores each playlist's metadata AND ordered items (file by relative path, zim by path+entry, url inline); round-trips a DB wipe. Supports backup import (DB_CONFIG_FILE_NAMES). - The redundant generic CollectionsConfig (collections.yaml) is deleted: channels/domains own their configs, authors/subjects are re-derived. On-disk sync (sync_playlists_directory switch): - Each playlist materializes into the configurable Playlists Directory (playlists_destination, Settings UI input, excluded from indexing) with position-prefixed entries: NNNN_<file> hard links (copy fallback across filesystems) and NNNN_<title>.html stubs for zim/url items. - Tagged playlists are namespaced like channels: <playlists>/<tag>/<name>; a tag change moves the directory and prunes the empty old tag dir. - Deletion safety: managed files are only removed when they are our marked stubs or still have another hard link (st_nlink > 1) -- never a last copy. Stray loose files at the playlists root are removed; stub URLs are scheme-allowlisted (no javascript:/data:). Frontend: - /playlists list page (CollectionTable, name filter, New Playlist modal, Tag column) between Files and Zim in the nav. - View-only detail page (Name link) and edit page (Edit button): themed table with model icons/colors + poster thumbnails, items link to their model page (video/archive/doc) or preview, up/down reorder, remove, add-URL form, single-tag selector; all editing disabled in WROL Mode. - Reusable green "Add to Playlist" button on Video/Archive/Doc detail pages, the FilePreview toolbar, Zim search results, and Map pins (added as a /map URL item with a map-marker icon). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Playlist edit page parity: rename, custom directory, tag modal move suggestion Match the ChannelEditPage/DomainEditPage UX on the playlist edit page: Back/View buttons, an edit form (name, description, directory), and the shared CollectionTagModal with the violet "Tag" button. The tag modal now suggests moving the playlist to its managed tag location (playlists/<tag>/<name>); accepting the suggestion stores directory=None so the sync keeps auto-managing the location. Tag buttons on the Channel and Domain edit pages are now violet to match. Backend: update_collection learns rename (with per-kind validation and duplicate checks) and playlist custom directories (cleanup of the abandoned directory, config round-trip, commit-before-switch to avoid the background sync racing pre-commit state). tag_collection's duplicated untag/tag branches are merged, and the per-kind config-save dispatch is consolidated into _activate_config_save(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deduplicate Collection error handling and Tag creation Delete the duplicate UnknownCollection in wrolpi/collections/errors.py and re-export the canonical one from wrolpi/errors.py, which carries status_code=NOT_FOUND (the local copy inherited 500). Replace the repeated query/raise pattern in reorganize.py with Collection.find_by_id. Add Tag.get_or_create_tag and use it for collection tagging; flush the new Tag before returning so callers can read .id immediately -- previously tagging a collection with a brand-new tag name set tag_id=None. Regression test included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:37:34 -06:00
{previewFile && previewFile['id'] &&
<AddToPlaylistButton fileGroupId={previewFile['id']} content={null} size='small'
title='Add to Playlist'/>}
</div>
<div className='preview-toolbar-tags'>{tagsDisplay}</div>
<div className='preview-toolbar-group'>
{openButton}
</div>
</div>
</Modal.Actions>
{content}
2023-02-15 17:40:57 -07:00
</Modal>);
}
2023-02-15 20:58:07 -07:00
React.useEffect(() => {
setPreviewModal(null);
if (previewFile && !_.isObject(previewFile)) {
console.error(`Unknown previewFile type: ${typeof previewFile}`);
toast({
type: 'error',
title: 'Cannot preview file',
description: 'Cannot preview file',
time: 5000,
});
return;
}
if (previewFile && !_.isEmpty(previewFile)) {
const {mimetype, size, taggable} = previewFile;
const path = previewFile['primary_path'] || previewFile['path'];
console.debug('Previewing file', previewFile);
const lowerPath = path.toLowerCase();
const url = getMediaPathURL(previewFile);
const downloadURL = getDownloadPathURL(previewFile);
2023-02-15 21:20:23 -07:00
if (mimetype.startsWith('text/') && size > MAXIMUM_TEXT_SIZE) {
2023-02-15 17:40:57 -07:00
// Large text files should be downloaded.
window.open(downloadURL);
} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {
setModalContent(getIframePreviewModal(previewFile), url, downloadURL, path, taggable);
2023-02-15 17:40:57 -07:00
} else if (mimetype.startsWith('video/')) {
setModalContent(getVideoPreviewModal(previewFile), url, downloadURL, path, taggable);
2023-02-22 11:48:25 -07:00
} else if (mimetype.startsWith('audio/')) {
setModalContent(getAudioPreviewModal(previewFile), url, downloadURL, path, taggable);
2023-02-15 17:40:57 -07:00
} else if (mimetype.startsWith('application/epub')) {
const viewerURL = getEpubViewerURL(previewFile);
setModalContent(getIframePreviewModal(previewFile, viewerURL), viewerURL, downloadURL, path, taggable);
} else if (mimetype.startsWith('application/pdf')) {
setModalContent(getIframePreviewModal(previewFile), url, downloadURL, path, taggable);
2023-02-15 17:40:57 -07:00
} else if (mimetype.startsWith('image/')) {
setModalContent(getImagePreviewModal(previewFile), url, null, path, taggable);
} else if (mimetype.startsWith('model/stl')) {
setModalContent(<STLPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
2026-04-15 13:37:05 -06:00
} else if (mimetype.startsWith('model/3mf')) {
setModalContent(<ThreeMFPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
} else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.mp3')) {
setModalContent(getAudioPreviewModal(previewFile), url, downloadURL, path, taggable);
} else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.stl')) {
setModalContent(<STLPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
2026-04-15 13:37:05 -06:00
} else if ((mimetype.startsWith('application/octet-stream') || mimetype === 'application/zip')
&& lowerPath.endsWith('.3mf')) {
setModalContent(<ThreeMFPreviewModal previewFile={previewFile}/>, null, downloadURL, path, taggable);
} else if (mimetype.includes('cbz') || mimetype.includes('cbr') || mimetype.includes('comicbook+zip') || mimetype.includes('comicbook-rar')
|| lowerPath.endsWith('.cbz') || lowerPath.endsWith('.cbr') || lowerPath.endsWith('.cbt') || lowerPath.endsWith('.cb7')) {
setModalContent(<Modal.Content><CbzViewer path={path}/></Modal.Content>, null, downloadURL, path, taggable);
} else if (isSupportedArchive(mimetype, lowerPath)) {
setModalContent(<ArchivePreviewContent previewFile={previewFile}/>, null, downloadURL, path, taggable);
2023-02-15 17:40:57 -07:00
} else {
// No special handler for this file type, just open it.
setModalContent(getGenericPreviewModal(previewFile), url, downloadURL, path, taggable);
2023-02-15 17:40:57 -07:00
}
// Trigger tracking for the file view (unless on an excluded route)
if (!skipTracking) {
getFile(path).catch(e => {
console.error(e);
console.error('Failed to get file to set FileGroup.viewed');
});
}
2023-02-15 17:40:57 -07:00
}
}, [previewFile, skipTracking]);
2023-02-15 17:40:57 -07:00
const value = {previewFile, setPreviewFile, previewModal, setPreviewModal, setCallbacks};
2023-02-15 20:58:07 -07:00
return <FilePreviewContext.Provider value={value}>
{children}
{previewModal}
{errorModal}
2023-02-15 20:58:07 -07:00
</FilePreviewContext.Provider>
2023-02-15 17:40:57 -07:00
}