import React, {useContext, useEffect, useState} from 'react';
import {Link, Route, Routes, useLocation, useNavigate, useParams, useSearchParams} from "react-router";
import {deleteFileGroups, getDocStatistics, tagFileGroup, untagFileGroup} from "../api";
import {Media, ThemeContext} from "../contexts/contexts";
import {
APIButton,
BackButton,
DirectoryLink,
encodeMediaPath,
ErrorMessage,
humanFileSize,
isoDatetimeToAgoPopup,
PageContainer,
PreviewPath,
SearchInput,
TabLinks,
toLocaleString,
useTitle
} from "./Common";
import {
Button,
ButtonGroup,
Group,
Header,
Loading,
MediaGate,
Panel,
Statistic,
StatisticGroup,
Tabs
} from "./ui";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {DeepSearchHint, docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
import {useAuthors, useDoc, useOneQuery, useSearchDocs, useSubjects} from "../hooks/customHooks";
import {TagsSelector} from "../Tags";
import {toast} from "./ui";
import {CollectionTable} from "./collections/CollectionTable";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {CbzViewer} from "./CbzViewer";
import _ from "lodash";
function DocsPage() {
useTitle('Documents');
const searchInputRef = React.useRef();
const {
docs,
totalPages,
activePage,
setPage,
searchStr,
setSearchStr,
author,
subject,
fetchDocs,
} = useSearchDocs();
const [selectedDocs, setSelectedDocs] = useState([]);
const [bulkTagOpen, setBulkTagOpen] = useState(false);
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
const onSelect = (path, checked) => {
if (checked && path) {
setSelectedDocs([...selectedDocs, path]);
} else if (path) {
setSelectedDocs(selectedDocs.filter(i => i !== path));
}
}
const onDelete = async (force = false) => {
const fileGroupIds = docs.filter(i => selectedDocs.indexOf(i['primary_path']) >= 0).map(i => i['id']);
const result = await deleteFileGroups(fileGroupIds, force);
if (result && result.tagged) {
setTaggedFileGroups(result.file_groups);
return;
}
await fetchDocs();
setSelectedDocs([]);
}
const invertSelection = () => {
const newSelectedDocs = docs.map(doc => doc['key']).filter(i => selectedDocs.indexOf(i) < 0);
setSelectedDocs(newSelectedDocs);
}
const clearSelection = (e) => {
if (e) e.preventDefault();
setSelectedDocs([]);
}
const onBulkTagComplete = async () => {
await fetchDocs();
setSelectedDocs([]);
}
const selectElm =
setBulkTagOpen(true)}>Tag
onDelete(false)}
obeyWROLMode={true}
>Delete
Invert
Clear
setBulkTagOpen(false)}
paths={selectedDocs}
onComplete={onBulkTagComplete}
/>
;
let docOrders = [
{value: 'published_datetime', text: 'Published Date', short: 'P.Date'},
{value: 'size', text: 'Size'},
{value: 'title', text: 'Title'},
];
if (searchStr) {
docOrders = [{value: 'rank', text: 'Rank'}, ...docOrders];
}
const {body, paginator, viewButton} = FilesView(
{
files: docs,
activePage: activePage,
totalPages: totalPages,
selectElem: selectElm,
selectedKeys: selectedDocs,
onSelect: onSelect,
setPage: setPage,
},
);
return <>
{author && }
{subject && }
{body}
{paginator}
setTaggedFileGroups(null)}
onConfirm={async () => {
setTaggedFileGroups(null);
await onDelete(true);
}}
/>
>;
}
function DocPage() {
const {fileGroupId} = useParams();
const navigate = useNavigate();
// Whether the theme is currently filtering media; a PDF is the one embed it cannot reach.
const {mediaFilterEnabled} = useContext(ThemeContext);
const {docFile, doc, fetchDoc} = useDoc(parseInt(fileGroupId));
const [searchParams] = useSearchParams();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
// Deep-link params: `loc` = EPUB spine index, `page` = PDF page, `q` = search term.
const locParam = searchParams.get('loc');
const pageParam = searchParams.get('page');
const qParam = searchParams.get('q');
const title = docFile ? (docFile.title || docFile.name) : null;
useTitle(title);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
if (docFile === null) {
return ;
}
if (docFile === undefined) {
return <>
This document does not exist
>;
}
const {data, size, directory} = docFile;
// Cover image.
const coverPath = data && data.cover_path ? data.cover_path : null;
const coverUrl = coverPath ? `/media/${encodeMediaPath(coverPath)}` : null;
// Filter files to those that can be viewed inline (EPUB, PDF, comic books).
const CBZ_EXTENSIONS = ['cbz', 'cbr', 'cbt', 'cb7'];
const isViewableMimetype = (mt) => {
if (!mt) return false;
if (mt.startsWith('application/epub')) return true;
if (mt === 'application/pdf') return true;
if (mt.includes('cbz') || mt.includes('cbr') || mt.includes('comicbook+zip') || mt.includes('comicbook-rar')) return true;
return false;
};
const viewableFiles = (docFile.files || [])
.filter(f => isViewableMimetype(f.mimetype) || CBZ_EXTENSIONS.includes((f.path || '').toLowerCase().split('.').pop()))
.sort((a, b) => {
if (String(a.path) === String(docFile.primary_path)) return -1;
if (String(b.path) === String(docFile.primary_path)) return 1;
return 0;
});
const selectedFile = viewableFiles[selectedFileIndex] || viewableFiles[0];
// Derive viewer state from the selected file (or fall back to primary).
const activePath = selectedFile ? selectedFile.path : docFile.primary_path;
const activeMimetype = selectedFile ? (selectedFile.mimetype || '') : (docFile.mimetype || '');
const activeExt = (String(activePath) || '').toLowerCase().split('.').pop();
const downloadUrl = activePath ? `/download/${encodeMediaPath(activePath)}` : null;
const isEpub = activeMimetype.startsWith('application/epub');
const isPdf = activeMimetype === 'application/pdf';
const isCbz = activeMimetype.includes('cbz') || activeMimetype.includes('cbr')
|| activeMimetype.includes('comicbook+zip') || activeMimetype.includes('comicbook-rar')
|| CBZ_EXTENSIONS.includes(activeExt);
// Build EPUB viewer URL, propagating any deep-link params through to epub.html.
let viewerUrl = null;
if (isEpub) {
const epubParams = new URLSearchParams();
epubParams.set('url', downloadUrl);
if (locParam) epubParams.set('loc', locParam);
if (qParam) epubParams.set('q', qParam);
viewerUrl = `/epub/epub.html?${epubParams.toString()}`;
}
const openUrl = viewerUrl || (activePath ? `/media/${encodeMediaPath(activePath)}` : null);
const canEmbed = isEpub || isPdf;
// PDFs use the browser's built-in viewer, which honors `#page=N` fragments.
const pdfSrc = isPdf && activePath
? `/media/${encodeMediaPath(activePath)}${pageParam ? `#page=${encodeURIComponent(pageParam)}` : ''}`
: null;
const embedUrl = isEpub ? viewerUrl : pdfSrc;
const handleDelete = async (force = false) => {
if (doc && doc.id) {
const result = await deleteFileGroups([docFile.id], force);
if (result && result.tagged) {
setTaggedFileGroups(result.file_groups);
return;
}
toast({
type: 'success',
title: 'Document Deleted',
description: 'Document was successfully deleted.',
time: 2000,
});
navigate(-1);
}
};
const localAddTag = async (name) => {
await tagFileGroup(docFile, name);
await fetchDoc();
};
const localRemoveTag = async (name) => {
await untagFileGroup(docFile, name);
await fetchDoc();
};
const publishedDatetimeString = docFile.published_datetime
? isoDatetimeToAgoPopup(docFile.published_datetime, true)
: null;
// Format badge based on mimetype.
const formatLabel = (overrideMt) => {
const mt = overrideMt || activeMimetype;
if (mt.startsWith('application/epub')) return 'EPUB';
if (mt === 'application/x-mobipocket-ebook') return 'MOBI';
if (mt === 'application/pdf') return 'PDF';
if (mt.includes('wordprocessingml')) return 'DOCX';
if (mt === 'application/msword') return 'DOC';
if (mt.includes('opendocument.text')) return 'ODT';
if (mt.includes('cbz') || mt.includes('comicbook+zip')) return 'CBZ';
if (mt.includes('cbr') || mt.includes('comicbook-rar')) return 'CBR';
if (mt.includes('cbt')) return 'CBT';
if (mt.includes('cb7') || mt.includes('x-7z')) return 'CB7';
return mt.split('/').pop().toUpperCase();
};
// About tab.
const aboutTab = <>
{doc && doc.description && <>
{doc.description}
>}
{doc && doc.publisher && <>
{doc.publisher}
>}
{doc && doc.language && <>
{doc.language}
>}
{doc && doc.subject && <>
{doc.subject}
>}
{doc && doc.page_count && <>
{doc.page_count}
>}
{humanFileSize(size)}
>;
// Files tab.
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const filesTab =
{docFile.files && docFile.files.map((file, idx) =>
{file.path.split('.').pop().toUpperCase()}
{file.path}
{file.size &&
({humanFileSize(file.size)})
}
)}
Directory
;
return <>
{isCbz && activePath && }
{canEmbed && embedUrl && !isCbz &&
{/*
* Only the PDF is gated. The EPUB viewer is our own same-origin document, so the
* theme's media filter reaches it; Chrome's PDF viewer is composited out of
* process and cannot be tinted. Keyed on the path so selecting a different file
* re-gates rather than inheriting the last one's reveal.
*/}
}
{docFile.title || docFile.name}
{docFile.author && }
Format: {formatLabel()}{doc && doc.page_count && ` (${doc.page_count} pages)`}
{publishedDatetimeString && Published: {publishedDatetimeString} }
{(() => {
const formatButtons = viewableFiles.length > 1 ?
{viewableFiles.map((file, idx) => {
const mt = file.mimetype || '';
let btnColor;
if (mt.startsWith('application/epub')) btnColor = 'yellow';
else if (mt === 'application/pdf') btnColor = 'red';
const isSelected = idx === selectedFileIndex;
return setSelectedFileIndex(idx)}
color={isSelected ? btnColor : undefined}
variant={isSelected ? 'filled' : 'default'}
>
{formatLabel(file.mimetype)}
;
})}
: null;
const actionButtons = <>
{openUrl &&
Open
}
{downloadUrl &&
Download
}
handleDelete(false)}
obeyWROLMode={true}
>Delete
>;
return <>
{formatButtons && {formatButtons}
}
{actionButtons}
{/*
* The format picker and the actions are one row, separated by its gap.
*
* This IS a change: the picker used to sit 1em from the actions, held
* there by a span with `margin-right`, and it now sits at the row's
* 0.625rem like everything else in a row. The two groups read as slightly
* closer than before. Deliberate -- one spacing rule for buttons in a row
* beats a second one for buttons in a row that happen to be a picker --
* but it is a visual change and not a pure refactor.
*/}
{formatButtons}
{actionButtons}
>;
})()}
About
Files
{aboutTab}
{filesTab}
setTaggedFileGroups(null)}
onConfirm={async () => {
setTaggedFileGroups(null);
await handleDelete(true);
}}
/>
>;
}
const AUTHOR_COLUMNS = [
{key: 'name', label: 'Author', sortable: true, width: 10},
{key: 'item_count', label: 'Documents', sortable: true, align: 'right', width: 6},
];
const AUTHOR_ROUTES = {
search: '/docs',
searchParam: 'author',
};
const SUBJECT_COLUMNS = [
{key: 'name', label: 'Subject', sortable: true, width: 10},
{key: 'item_count', label: 'Documents', sortable: true, align: 'right', width: 6},
];
const SUBJECT_ROUTES = {
search: '/docs',
searchParam: 'subject',
};
function AuthorsPage() {
useTitle('Doc Authors');
const [authors] = useAuthors();
const [searchStr, setSearchStr] = useOneQuery('author');
const searchInputRef = React.useRef();
// No `marginBottom`: a top-level block of the page, spaced by the page's stack.
const header =
setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
;
return <>
{header}
>;
}
function SubjectsPage() {
useTitle('Doc Subjects');
const [subjects] = useSubjects();
const [searchStr, setSearchStr] = useOneQuery('subject');
const searchInputRef = React.useRef();
// No `marginBottom`: a top-level block of the page, spaced by the page's stack.
const header =
setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
;
return <>
{header}
>;
}
function DocsStatisticsPage() {
useTitle('Doc Statistics');
const [statistics, setStatistics] = useState(null);
useEffect(() => {
const fetchStats = async () => {
try {
const response = await getDocStatistics();
if (response.ok) {
const data = await response.json();
setStatistics(data.statistics);
}
} catch (e) {
setStatistics(undefined);
}
};
fetchStats();
}, []);
if (statistics === null) {
return ;
}
if (statistics === undefined) {
return Unable to fetch Doc Statistics ;
}
return
;
}
export function DocsRoute() {
const location = useLocation();
const path = location.pathname;
const links = [
{
text: 'Docs',
to: '/docs',
end: true,
isActive: () => path === '/docs' || /^\/docs\/\d+$/.test(path)
},
{text: 'Subjects', to: '/docs/subjects', isActive: () => path.startsWith('/docs/subjects')},
{text: 'Authors', to: '/docs/authors', isActive: () => path.startsWith('/docs/authors')},
{text: 'Statistics', to: '/docs/statistics'},
];
return
}/>
}/>
}/>
}/>
}/>
;
}