mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Creating sort button which allows the user to select a sort order via modal.
This commit is contained in:
parent
3b3cea4d70
commit
bab10a0c96
14 changed files with 531 additions and 263 deletions
|
|
@ -76,13 +76,13 @@ export function Dashboard() {
|
|||
// Only show dashboard parts if not searching.
|
||||
let body;
|
||||
if (searchStr || (activeTags && activeTags.length > 0)) {
|
||||
body = <FilesSearchView showLimit={true} showSelectButton={true}/>;
|
||||
body = <FilesSearchView showSelectButton={true}/>;
|
||||
} else {
|
||||
body = <>
|
||||
{!wrol_mode && downloads}
|
||||
{/* Hide Status when user is starting a download */}
|
||||
{/* Hide extras when user is starting a download */}
|
||||
{!downloadOpen && <DashboardStatus/>}
|
||||
<TagsDashboard/>
|
||||
{!downloadOpen && <TagsDashboard/>}
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {Segment} from "./components/Theme";
|
|||
import _ from "lodash";
|
||||
import {HexColorPicker} from "react-colorful";
|
||||
import {useRecurringTimeout} from "./hooks/customHooks";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
export const TagsContext = React.createContext({
|
||||
NameToTagLabel: null,
|
||||
|
|
@ -168,7 +169,7 @@ function EditTagsModal() {
|
|||
const localOnClose = () => {
|
||||
setOpen(false);
|
||||
setTagName('');
|
||||
setTagColor(null);
|
||||
setTagColor(DEFAULT_TAG_COLOR);
|
||||
setTagId(null);
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +257,14 @@ function EditTagsModal() {
|
|||
</>
|
||||
}
|
||||
|
||||
export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd = _.noop, onRemove = _.noop}) {
|
||||
export function AddTagsButton({
|
||||
hideEdit,
|
||||
active,
|
||||
selectedTagNames = [],
|
||||
onToggle = _.noop,
|
||||
onAdd = _.noop,
|
||||
onRemove = _.noop
|
||||
}) {
|
||||
// A button which displays a modal in which the user can add or remove tags.
|
||||
|
||||
const {tagNames, TagsGroup} = React.useContext(TagsContext);
|
||||
|
|
@ -300,7 +308,7 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd =
|
|||
const unusedTagsGroup = <TagsGroup tagNames={unusedTags} onClick={addTag}/>;
|
||||
|
||||
return <>
|
||||
<SButton icon='tag' onClick={handleOpen} color='violet'/>
|
||||
<SButton icon={active ? 'tags' : 'tag'} onClick={handleOpen} primary={!!active}/>
|
||||
<Modal closeIcon
|
||||
open={open}
|
||||
onOpen={(e) => handleOpen(e)}
|
||||
|
|
@ -316,7 +324,16 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd =
|
|||
{unusedTags && unusedTags.length > 0 ? unusedTagsGroup : 'You have no tags'}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<EditTagsModal/>
|
||||
<Grid textAlign='left'>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={8}>
|
||||
{!hideEdit && <EditTagsModal/>}
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8}>
|
||||
<SButton onClick={() => setOpen(false)} floated='right'>Close</SButton>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</>
|
||||
|
|
@ -324,7 +341,15 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd =
|
|||
|
||||
export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'};
|
||||
|
||||
export const TagsSelector = ({selectedTagNames = [], onToggle = _.noop, onAdd = _.noop, onRemove = _.noop}) => {
|
||||
export const TagsSelector = ({
|
||||
hideEdit = false,
|
||||
active,
|
||||
hideGroup = false,
|
||||
selectedTagNames = [],
|
||||
onToggle = _.noop,
|
||||
onAdd = _.noop,
|
||||
onRemove = _.noop
|
||||
}) => {
|
||||
// Provides a button to add tags to a list. Displays the tags of that list.
|
||||
const {TagsLinkGroup} = React.useContext(TagsContext);
|
||||
|
||||
|
|
@ -333,11 +358,23 @@ export const TagsSelector = ({selectedTagNames = [], onToggle = _.noop, onAdd =
|
|||
return <></>;
|
||||
}
|
||||
|
||||
const button = <AddTagsButton
|
||||
hideEdit={hideEdit}
|
||||
active={active}
|
||||
selectedTagNames={selectedTagNames}
|
||||
onToggle={onToggle}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
/>;
|
||||
|
||||
if (hideGroup) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return <Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column mobile={2} computer={1}>
|
||||
<AddTagsButton selectedTagNames={selectedTagNames} onToggle={onToggle} onAdd={onAdd}
|
||||
onRemove={onRemove}/>
|
||||
{button}
|
||||
</Grid.Column>
|
||||
<Grid.Column mobile={13} computer={14}>
|
||||
<TagsLinkGroup tagNames={selectedTagNames}/>
|
||||
|
|
@ -389,7 +426,7 @@ export const TagsDropdown = ({value = [], onChange, ...props}) => {
|
|||
options = [...options, ...tagOptions];
|
||||
}
|
||||
|
||||
return <Dropdown multiple selection clearable
|
||||
return <Dropdown multiple selection clearable fluid
|
||||
options={options}
|
||||
placeholder='Tags'
|
||||
onChange={handleChange}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
Confirm,
|
||||
Container,
|
||||
Image,
|
||||
Input,
|
||||
Dropdown,
|
||||
Input, Modal,
|
||||
PlaceholderHeader,
|
||||
PlaceholderLine,
|
||||
TableCell,
|
||||
|
|
@ -27,7 +28,7 @@ import {
|
|||
mimetypeColor,
|
||||
PageContainer,
|
||||
PreviewLink,
|
||||
SearchInput,
|
||||
SearchInput, SortButton,
|
||||
TabLinks,
|
||||
textEllipsis,
|
||||
useTitle
|
||||
|
|
@ -35,12 +36,11 @@ import {
|
|||
import {addTag, deleteArchives, postDownload, removeTag} from "../api";
|
||||
import {Link, Route, Routes, useNavigate, useParams} from "react-router-dom";
|
||||
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
||||
import {useArchive, useDomains, useQuery, useSearchArchives} from "../hooks/customHooks";
|
||||
import {FileCards, FileRowTagIcon, FilesView} from "./Files";
|
||||
import {useArchive, useDomains, useQuery, useSearchArchives, useSearchDomain} from "../hooks/customHooks";
|
||||
import {FileCards, FileRowTagIcon, FilesView, SearchFilter} from "./Files";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import Dropdown from "semantic-ui-react/dist/commonjs/modules/Dropdown";
|
||||
import _ from "lodash";
|
||||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {Media, ThemeContext} from "../contexts/contexts";
|
||||
import {Button, Card, CardIcon, Header, Loader, Placeholder, Segment} from "./Theme";
|
||||
import {SortableTable} from "./SortableTable";
|
||||
import {taggedImageLabel, TagsSelector} from "../Tags";
|
||||
|
|
@ -236,7 +236,7 @@ export function ArchiveCard({file}) {
|
|||
</Card>
|
||||
}
|
||||
|
||||
export function Domains() {
|
||||
export function DomainsPage() {
|
||||
useTitle('Archive Domains');
|
||||
|
||||
const [domains] = useDomains();
|
||||
|
|
@ -297,20 +297,60 @@ export function Domains() {
|
|||
</>;
|
||||
}
|
||||
|
||||
export function SearchDomain() {
|
||||
// A Dropdown which allows the user to filter by Archive domains.
|
||||
const {domain, domains, setDomain} = useSearchDomain();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpen = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const handleClose = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const handleChange = (e, {value}) => {
|
||||
setDomain(value);
|
||||
}
|
||||
|
||||
if (domains && domains.length > 0) {
|
||||
const domainOptions = domains.map(i => {
|
||||
return {key: i['domain'], value: i['domain'], text: i['domain']}
|
||||
});
|
||||
return <>
|
||||
<Modal closeIcon
|
||||
open={open}
|
||||
onOpen={() => handleOpen()}
|
||||
onClose={() => handleClose()}
|
||||
>
|
||||
<Modal.Content>
|
||||
domains
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
<Dropdown selection search clearable fluid
|
||||
placeholder='Domains'
|
||||
options={domainOptions}
|
||||
onChange={handleChange}
|
||||
value={domain}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
|
||||
function Archives() {
|
||||
const [domains] = useDomains();
|
||||
const [selectedArchives, setSelectedArchives] = useState([]);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
useTitle('Archives');
|
||||
|
||||
let filterOptions = [];
|
||||
if (!_.isEmpty(domains)) {
|
||||
domains.forEach(i => {
|
||||
filterOptions = [...filterOptions, {text: i['domain'], key: i['domain'], value: i['domain']}]
|
||||
});
|
||||
}
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const {searchParams} = useQuery();
|
||||
|
||||
let searchOrder = '-date';
|
||||
if (searchParams.get('order')) {
|
||||
|
|
@ -321,50 +361,25 @@ function Archives() {
|
|||
searchOrder = defaultSearchOrder;
|
||||
}
|
||||
|
||||
const domain = searchParams.get('domain');
|
||||
const {
|
||||
archives,
|
||||
limit,
|
||||
setLimit,
|
||||
totalPages,
|
||||
activePage,
|
||||
setPage,
|
||||
searchStr,
|
||||
setSearchStr,
|
||||
setOrderBy,
|
||||
fetchArchives,
|
||||
} = useSearchArchives(24, domain, searchOrder);
|
||||
const setView = (value) => updateQuery({view: value});
|
||||
const view = searchParams.get('view');
|
||||
const setDomain = (value) => updateQuery({'domain': value, 'o': 0});
|
||||
} = useSearchArchives();
|
||||
|
||||
let archiveOrders = [
|
||||
{key: '-date', value: '-date', text: 'Newest'},
|
||||
{key: 'date', value: 'date', text: 'Oldest'},
|
||||
{value: 'date', text: 'Date'},
|
||||
{value: 'size', text: 'Size'},
|
||||
];
|
||||
|
||||
if (searchStr) {
|
||||
archiveOrders = [{key: 'rank', value: 'rank', text: 'Search Rank'}, ...archiveOrders];
|
||||
archiveOrders = [{value: 'rank', text: 'Rank'}, ...archiveOrders];
|
||||
}
|
||||
|
||||
const menuColumns = (<>
|
||||
<Grid.Column width={5}>
|
||||
<SearchInput clearable
|
||||
actionIcon='search'
|
||||
searchStr={searchStr}
|
||||
onSubmit={setSearchStr}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={5}>
|
||||
<Dropdown selection fluid
|
||||
placeholder='Sort by...'
|
||||
value={searchOrder}
|
||||
options={archiveOrders}
|
||||
onChange={(e, {value}) => setOrderBy(value)}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</>);
|
||||
|
||||
const onSelect = (path, checked) => {
|
||||
if (checked && path) {
|
||||
setSelectedArchives([...selectedArchives, path]);
|
||||
|
|
@ -421,27 +436,56 @@ function Archives() {
|
|||
</Button>
|
||||
</div>;
|
||||
|
||||
return <FilesView
|
||||
files={archives}
|
||||
limit={limit}
|
||||
setLimit={setLimit}
|
||||
activePage={activePage}
|
||||
totalPages={totalPages}
|
||||
showLimit={true}
|
||||
showSelect={true}
|
||||
onSelect={onSelect}
|
||||
selectElem={selectElm}
|
||||
selectedKeys={selectedArchives}
|
||||
view={view}
|
||||
setView={setView}
|
||||
setPage={setPage}
|
||||
filterOptions={filterOptions}
|
||||
activeFilters={domain}
|
||||
setFilters={setDomain}
|
||||
filterPlaceholder='Domain'
|
||||
menuColumns={menuColumns}
|
||||
menuColumnsCount={2}
|
||||
/>
|
||||
const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView(
|
||||
archives,
|
||||
activePage,
|
||||
totalPages,
|
||||
selectElm,
|
||||
selectedArchives,
|
||||
onSelect,
|
||||
setPage,
|
||||
);
|
||||
|
||||
const searchInput = <SearchInput clearable
|
||||
actionIcon='search'
|
||||
searchStr={searchStr}
|
||||
onSubmit={setSearchStr}
|
||||
/>;
|
||||
|
||||
return <>
|
||||
<Media at='mobile'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={2}>{selectButton}</Grid.Column>
|
||||
<Grid.Column width={2}>{viewButton}</Grid.Column>
|
||||
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={6}><SortButton sorts={archiveOrders}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={10}>{searchInput}</Grid.Column>
|
||||
<Grid.Column width={6}><SearchDomain/></Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
<Media greaterThanOrEqual='tablet'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={1}>{selectButton}</Grid.Column>
|
||||
<Grid.Column width={1}>{viewButton}</Grid.Column>
|
||||
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={5}><SortButton sorts={archiveOrders}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={8}>{searchInput}</Grid.Column>
|
||||
<Grid.Column width={6}><SearchDomain/></Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
{body}
|
||||
{paginator}
|
||||
</>
|
||||
}
|
||||
|
||||
export function ArchiveRowCells({file}) {
|
||||
|
|
@ -483,7 +527,7 @@ export function ArchiveRoute() {
|
|||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' element={<Archives/>}/>
|
||||
<Route path='domains' element={<Domains/>}/>
|
||||
<Route path='domains' element={<DomainsPage/>}/>
|
||||
<Route path=':archiveId' element={<ArchivePage/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {Card, Container, IconGroup, Input, Modal, Pagination} from 'semantic-ui-react';
|
||||
import {Button as SButton, ButtonGroup, Card, Container, IconGroup, Input, Modal, Pagination} from 'semantic-ui-react';
|
||||
import {Link, NavLink, useNavigate} from "react-router-dom";
|
||||
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
||||
import {useDirectories, useDownloads, useHotspot, useSettings, useThrottle} from "../hooks/customHooks";
|
||||
import {useDirectories, useHotspot, useSearchOrder, useSettings, useThrottle} from "../hooks/customHooks";
|
||||
import {Media, StatusContext, ThemeContext} from "../contexts/contexts";
|
||||
import {Button, CardIcon, darkTheme, Form, Header, Icon, lightTheme, Menu, Popup, Statistic} from "./Theme";
|
||||
import {FilePreviewContext} from "./FilePreview";
|
||||
|
|
@ -933,6 +933,8 @@ export const filterToMimetypes = (filter) => {
|
|||
return ['image'];
|
||||
} else if (filter === 'zip') {
|
||||
return ['application/zip', 'application/zlib', 'application/x-bzip2', 'application/x-xz', 'application/x-bzip', 'application/x-bzip2', 'application/gzip', 'application/vnd.rar', 'application/x-tar', 'application/x-7z-compressed'];
|
||||
} else if (filter === 'model') {
|
||||
return ['application/x-openscad', 'model/stl', 'application/sla', 'model/obj'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -975,3 +977,64 @@ export const encodeMediaPath = (path) => {
|
|||
path = path.replaceAll(' ', '%20');
|
||||
return path
|
||||
}
|
||||
|
||||
export function SortButton({sorts = []}) {
|
||||
const {sort, setSort} = useSearchOrder();
|
||||
|
||||
if (!sorts || (sorts && sorts.length === 0)) {
|
||||
console.error('No sorts have been defined!');
|
||||
}
|
||||
|
||||
// Use the order in the URL query, fallback to the first in the orders array.
|
||||
const defaultDesc = sort ? sort.startsWith('-') : true;
|
||||
const defaultSort = sort ? sort.replaceAll(/^-/g, '') : sorts[0]['value'];
|
||||
|
||||
const [localSort, setLocalSort] = useState(defaultSort);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [desc, setDesc] = useState(defaultDesc);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSort) {
|
||||
const newSort = desc ? `-${localSort}` : localSort;
|
||||
console.debug(`Setting new sort: ${newSort}`)
|
||||
setSort(newSort);
|
||||
}
|
||||
}, [localSort, desc]);
|
||||
|
||||
const handleSortButton = (o) => {
|
||||
setLocalSort(o);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const toggleDesc = () => {
|
||||
setDesc(!desc);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
let sortFields;
|
||||
if (sorts && sorts.length) {
|
||||
sortFields = sorts.map((i) => {
|
||||
return <SButton key={i['value']} onClick={() => handleSortButton(i['value'])}>{i['text']}</SButton>
|
||||
})
|
||||
}
|
||||
|
||||
// Remove the - from the front of the query sort.
|
||||
const sortKey = localSort.replaceAll(/^-/g, '');
|
||||
const selectedSort = sorts.find(i => i['value'] === sortKey);
|
||||
|
||||
return <>
|
||||
<Modal closeIcon
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<Modal.Header>Sort By</Modal.Header>
|
||||
<Modal.Content>
|
||||
{sortFields}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
<ButtonGroup icon>
|
||||
<Button icon={desc ? 'sort down' : 'sort up'} onClick={() => toggleDesc()}/>
|
||||
<Button content={selectedSort['text']} onClick={() => setOpen(true)}/>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import React, {useContext} from "react";
|
||||
import React, {useContext, useState} from "react";
|
||||
import {
|
||||
Button as SButton,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardMeta,
|
||||
Checkbox,
|
||||
Container,
|
||||
Dropdown,
|
||||
Form,
|
||||
Image,
|
||||
Label,
|
||||
Modal,
|
||||
PlaceholderLine,
|
||||
TableCell,
|
||||
TableHeaderCell,
|
||||
|
|
@ -29,12 +33,19 @@ import {
|
|||
textEllipsis,
|
||||
useTitle
|
||||
} from "./Common";
|
||||
import {useFilesProgressInterval, useQuery, useSearchFiles} from "../hooks/customHooks";
|
||||
import {
|
||||
useFilesProgressInterval,
|
||||
usePages,
|
||||
useQuery,
|
||||
useSearchFiles,
|
||||
useSearchFilter,
|
||||
useSearchView
|
||||
} from "../hooks/customHooks";
|
||||
import {Route, Routes} from "react-router-dom";
|
||||
import {CardPlacholder} from "./Placeholder";
|
||||
import {ArchiveCard, ArchiveRowCells} from "./Archive";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {StatusContext, ThemeContext} from "../contexts/contexts";
|
||||
import {Media, StatusContext, ThemeContext} from "../contexts/contexts";
|
||||
import {Button, Card, CardIcon, Icon, Placeholder, Progress, Segment} from "./Theme";
|
||||
import {SelectableTable} from "./Tables";
|
||||
import {VideoCard, VideoRowCells} from "./Videos";
|
||||
|
|
@ -42,7 +53,7 @@ import _ from 'lodash';
|
|||
import {FileBrowser} from "./FileBrowser";
|
||||
import {refreshDirectoryFiles, refreshFiles} from "../api";
|
||||
import {useSubscribeEventName} from "../Events";
|
||||
import {TagsDropdown} from "../Tags";
|
||||
import {TagsSelector} from "../Tags";
|
||||
|
||||
function EbookCard({file}) {
|
||||
const {s} = useContext(ThemeContext);
|
||||
|
|
@ -253,69 +264,53 @@ export function FileRowTagIcon({file}) {
|
|||
}
|
||||
}
|
||||
|
||||
export function FilesView({
|
||||
files,
|
||||
limit,
|
||||
setLimit,
|
||||
showLimit = false,
|
||||
activePage,
|
||||
totalPages,
|
||||
view,
|
||||
setView,
|
||||
showView = true,
|
||||
showSelect = false,
|
||||
selectElem,
|
||||
selectedKeys,
|
||||
onSelect,
|
||||
setPage,
|
||||
menuColumnsCount,
|
||||
menuColumns,
|
||||
filterOptions,
|
||||
activeFilters,
|
||||
setFilters,
|
||||
multipleFilters,
|
||||
filterPlaceholder,
|
||||
}) {
|
||||
export function SearchViewButton() {
|
||||
const {view, setView} = useSearchView();
|
||||
|
||||
if (view === 'list') {
|
||||
return <Button icon='th' onClick={() => setView('cards')}/>;
|
||||
}
|
||||
return <Button icon='browser' onClick={() => setView('list')}/>;
|
||||
}
|
||||
|
||||
export function SearchLimitDropdown({limits = []}) {
|
||||
const {limit, setLimit} = usePages();
|
||||
const limitOptions = limits.map(i => {
|
||||
return {key: i, value: i, text: i.toString()}
|
||||
});
|
||||
|
||||
return <Dropdown fluid selection
|
||||
placeholder='Limit'
|
||||
options={limitOptions}
|
||||
value={parseInt(limit || limitOptions[0]['value'])}
|
||||
onChange={(e, {value}) => setLimit(value)}
|
||||
/>
|
||||
}
|
||||
|
||||
export function FilesView(
|
||||
files,
|
||||
activePage,
|
||||
totalPages,
|
||||
selectElem,
|
||||
selectedKeys,
|
||||
onSelect,
|
||||
setPage,
|
||||
limitOptions = [12, 24, 48, 96],
|
||||
) {
|
||||
const {view} = useSearchView();
|
||||
|
||||
let selectButton;
|
||||
const [selectOn, setSelectOn] = React.useState(false);
|
||||
const toggleSelectOn = () => setSelectOn(!selectOn);
|
||||
const selectButtonDisabled = view !== 'list';
|
||||
if (showSelect && selectOn) {
|
||||
if (selectOn) {
|
||||
selectButton = <Button active disabled={selectButtonDisabled} icon='checkmark box'
|
||||
onClick={toggleSelectOn}/>;
|
||||
} else if (showSelect) {
|
||||
} else {
|
||||
selectButton = <Button icon='checkmark box' disabled={selectButtonDisabled}
|
||||
onClick={toggleSelectOn}/>;
|
||||
}
|
||||
|
||||
let viewButton;
|
||||
if (showView) {
|
||||
if (view === 'list') {
|
||||
viewButton = <Button icon='th' onClick={() => setView('cards')}/>;
|
||||
} else {
|
||||
viewButton = <Button icon='browser' onClick={() => setView('list')}/>;
|
||||
}
|
||||
}
|
||||
|
||||
let limitDropdown;
|
||||
const limitOptions = [
|
||||
{key: 12, value: 12, text: '12'},
|
||||
{key: 24, value: 24, text: '24'},
|
||||
{key: 48, value: 48, text: '48'},
|
||||
{key: 96, value: 96, text: '96'},
|
||||
];
|
||||
if (showLimit) {
|
||||
limitDropdown = <Dropdown
|
||||
compact
|
||||
selection
|
||||
placeholder='Limit'
|
||||
options={limitOptions}
|
||||
value={parseInt(limit || limitOptions[0]['value'])}
|
||||
onChange={(e, {value}) => setLimit(value)}
|
||||
/>
|
||||
}
|
||||
|
||||
const paginator = <center style={{marginTop: '2em'}}>
|
||||
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
|
||||
</center>;
|
||||
|
|
@ -326,38 +321,87 @@ export function FilesView({
|
|||
<>
|
||||
<TableHeaderCell colSpan='3'>{selectElem}</TableHeaderCell>
|
||||
</> : null;
|
||||
body = <FileTable files={files} selectOn={selectOn}
|
||||
onSelect={onSelect} footer={footer} selectedKeys={selectedKeys}/>;
|
||||
body = <FileTable
|
||||
files={files}
|
||||
selectOn={selectOn}
|
||||
onSelect={onSelect}
|
||||
footer={footer}
|
||||
selectedKeys={selectedKeys || []}
|
||||
/>;
|
||||
} else {
|
||||
body = <FileCards files={files}/>;
|
||||
}
|
||||
|
||||
const filtersDropdown = <Dropdown selection clearable search
|
||||
multiple={multipleFilters || undefined}
|
||||
options={filterOptions || []}
|
||||
placeholder={filterPlaceholder || 'Files Types'}
|
||||
onChange={(e, {value}) => setFilters(value)}
|
||||
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
|
||||
value={activeFilters}
|
||||
/>;
|
||||
const viewButton = <SearchViewButton/>
|
||||
const limitDropdown = <SearchLimitDropdown limits={limitOptions}/>;
|
||||
const tagQuerySelector = <TagsQuerySelector/>;
|
||||
|
||||
return <>
|
||||
<Grid columns={menuColumnsCount || 1} stackable>
|
||||
<Grid.Column mobile={16} computer={12}>
|
||||
{selectButton}
|
||||
{viewButton}
|
||||
{limitDropdown}
|
||||
<TagsFilesGroupDropdown/>
|
||||
{filterOptions && filtersDropdown}
|
||||
</Grid.Column>
|
||||
{menuColumns}
|
||||
</Grid>
|
||||
{body}
|
||||
{paginator}
|
||||
</>
|
||||
return {
|
||||
body,
|
||||
paginator,
|
||||
selectButton,
|
||||
viewButton,
|
||||
limitDropdown,
|
||||
tagQuerySelector,
|
||||
}
|
||||
}
|
||||
|
||||
export function TagsFilesGroupDropdown({onChange}) {
|
||||
export function SearchFilter({filters = [], modalHeader}) {
|
||||
const {filter, setFilter} = useSearchFilter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpen = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const handleClear = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setFilter(null);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const filterFields = filters.map(
|
||||
i => <Form.Field key={i['text']}>
|
||||
<Checkbox radio
|
||||
label={i['text']}
|
||||
name='searchFilterRadioGroup'
|
||||
checked={filter === i['value']}
|
||||
value={i['value']}
|
||||
onChange={() => setFilter(i['value'])}
|
||||
/>
|
||||
</Form.Field>
|
||||
);
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
return <>
|
||||
<Modal open={open} onOpen={() => handleOpen()} onClose={() => setOpen(false)} closeIcon>
|
||||
{modalHeader || <Modal.Header>Filter</Modal.Header>}
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
{filterFields}
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<SButton onClick={handleClear} secondary>Clear</SButton>
|
||||
<SButton onClick={() => setOpen(false)}>Close</SButton>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
<Button
|
||||
icon='filter'
|
||||
onClick={handleOpen}
|
||||
primary={!!filter}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
|
||||
export function TagsQuerySelector({onChange}) {
|
||||
// Creates dropdown that the User can use to manipulate the tag query.
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
|
|
@ -369,24 +413,22 @@ export function TagsFilesGroupDropdown({onChange}) {
|
|||
}
|
||||
}
|
||||
|
||||
return <TagsDropdown
|
||||
value={activeTags}
|
||||
onChange={localOnChange}
|
||||
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
|
||||
return <TagsSelector hideGroup={true}
|
||||
hideEdit={true}
|
||||
active={activeTags && activeTags.length > 0}
|
||||
selectedTagNames={activeTags}
|
||||
onToggle={localOnChange}
|
||||
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
|
||||
/>
|
||||
}
|
||||
|
||||
export function FilesSearchView({
|
||||
showView = true,
|
||||
showLimit = false,
|
||||
showSelect = false,
|
||||
emptySearch = false,
|
||||
filterOptions,
|
||||
model,
|
||||
onSelect,
|
||||
setFilters,
|
||||
}) {
|
||||
filterOptions = filterOptions || [
|
||||
const filterOptions = [
|
||||
{key: 'video', text: 'Video', value: 'video'},
|
||||
{key: 'archive', text: 'Archive', value: 'archive'},
|
||||
{key: 'pdf', text: 'PDF', value: 'pdf'},
|
||||
|
|
@ -394,32 +436,49 @@ export function FilesSearchView({
|
|||
{key: 'audio', text: 'Audio', value: 'audio'},
|
||||
{key: 'image', text: 'Image', value: 'image'},
|
||||
{key: 'zip', text: 'ZIP', value: 'zip'},
|
||||
{key: 'model', text: '3D Model', value: 'model'},
|
||||
];
|
||||
|
||||
const {searchFiles, limit, setLimit, totalPages, activePage, setPage} =
|
||||
const {searchFiles, totalPages, activePage, setPage} =
|
||||
useSearchFiles(24, emptySearch, model);
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const setView = (value) => updateQuery({view: value});
|
||||
const view = searchParams.get('view');
|
||||
|
||||
const setFilter = (value) => updateQuery({'filter': value});
|
||||
const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView(
|
||||
searchFiles,
|
||||
activePage,
|
||||
totalPages,
|
||||
setPage,
|
||||
);
|
||||
|
||||
return <FilesView
|
||||
files={searchFiles}
|
||||
limit={limit}
|
||||
setLimit={setLimit}
|
||||
showLimit={showLimit}
|
||||
activePage={activePage}
|
||||
totalPages={totalPages}
|
||||
view={view}
|
||||
setView={setView}
|
||||
showView={showView}
|
||||
showSelect={showSelect}
|
||||
onSelect={onSelect}
|
||||
setPage={setPage}
|
||||
filterOptions={filterOptions}
|
||||
setFilters={setFilters || setFilter}
|
||||
/>
|
||||
return <>
|
||||
<Media at='mobile'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
{showSelect &&
|
||||
<Grid.Column width={2}>{selectButton}</Grid.Column>}
|
||||
{showView &&
|
||||
<Grid.Column width={2}>{viewButton}</Grid.Column>}
|
||||
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={2}><SearchFilter filters={filterOptions}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
<Media greaterThanOrEqual='tablet'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
{showSelect &&
|
||||
<Grid.Column width={1}>{selectButton}</Grid.Column>}
|
||||
{showView &&
|
||||
<Grid.Column width={1}>{viewButton}</Grid.Column>}
|
||||
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={1}><SearchFilter filters={filterOptions}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
{body}
|
||||
{paginator}
|
||||
</>
|
||||
}
|
||||
|
||||
export function FilesRefreshProgress() {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
mimetypeColor,
|
||||
PageContainer,
|
||||
scrollToTop,
|
||||
SearchInput,
|
||||
SearchInput, SortButton,
|
||||
TabLinks,
|
||||
textEllipsis,
|
||||
useTitle
|
||||
|
|
@ -41,8 +41,9 @@ import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
|||
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
|
||||
import {Button, Card, Header, Loader, Placeholder, Segment, Statistic, StatisticGroup} from "./Theme";
|
||||
import {deleteVideos} from "../api";
|
||||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {Media, ThemeContext} from "../contexts/contexts";
|
||||
import _ from "lodash";
|
||||
import {SearchDomain} from "./Archive";
|
||||
|
||||
export function VideoWrapper() {
|
||||
const {videoId} = useParams();
|
||||
|
|
@ -58,7 +59,7 @@ export function VideoWrapper() {
|
|||
function VideosPage() {
|
||||
|
||||
const {channelId} = useParams();
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const {searchParams} = useQuery();
|
||||
const [selectedVideos, setSelectedVideos] = useState([]);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -71,10 +72,8 @@ function VideosPage() {
|
|||
searchOrder = defaultSearchOrder;
|
||||
}
|
||||
|
||||
const {searchStr, setSearchStr, videos, activePage, setPage, limit, setLimit, totalPages, setOrderBy, fetchVideos} =
|
||||
const {searchStr, setSearchStr, videos, activePage, setPage, totalPages, fetchVideos} =
|
||||
useSearchVideos(null, channelId, searchOrder);
|
||||
const setView = (value) => updateQuery({view: value});
|
||||
const view = searchParams.get('view');
|
||||
|
||||
const {channel} = useChannel(channelId);
|
||||
|
||||
|
|
@ -85,38 +84,16 @@ function VideosPage() {
|
|||
useTitle(title);
|
||||
|
||||
let videoOrders = [
|
||||
{key: '-upload_date', value: '-upload_date', text: 'Newest'},
|
||||
{key: 'upload_date', value: 'upload_date', text: 'Oldest'},
|
||||
{key: '-duration', value: '-duration', text: 'Longest'},
|
||||
{key: 'duration', value: 'duration', text: 'Shortest'},
|
||||
{key: '-viewed', value: '-viewed', text: 'Recently viewed'},
|
||||
{key: '-size', value: '-size', text: 'Largest'},
|
||||
{key: 'size', value: 'size', text: 'Smallest'},
|
||||
{key: '-view_count', value: '-view_count', text: 'Most Views'},
|
||||
{key: 'view_count', value: 'view_count', text: 'Least Views'},
|
||||
{key: '-modification_datetime', value: '-modification_datetime', text: 'Newest File'},
|
||||
{key: 'modification_datetime', value: 'modification_datetime', text: 'Oldest File'},
|
||||
];
|
||||
{value: 'upload_date', text: 'Date'},
|
||||
{value: 'duration', text: 'Duration'},
|
||||
{value: 'size', text: 'Size'},
|
||||
{value: 'view_count', text: 'Views'},
|
||||
]
|
||||
|
||||
if (searchStr) {
|
||||
videoOrders = [{key: 'rank', value: 'rank', text: 'Search Rank'}, ...videoOrders];
|
||||
videoOrders = [{value: 'rank', text: 'Search'}, ...videoOrders];
|
||||
}
|
||||
|
||||
const menuColumns = <Fragment>
|
||||
<Grid.Column mobile={8} computer={5}>
|
||||
<SearchInput clearable searchStr={searchStr} onSubmit={setSearchStr} actionIcon='search'/>
|
||||
</Grid.Column>
|
||||
<Grid.Column mobile={6} computer={5}>
|
||||
<Dropdown selection fluid
|
||||
size='large'
|
||||
placeholder='Sort by...'
|
||||
value={searchOrder}
|
||||
options={videoOrders}
|
||||
onChange={(e, {value}) => setOrderBy(value)}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Fragment>;
|
||||
|
||||
let header;
|
||||
if (channel && channel.name) {
|
||||
const editLink = `/videos/channel/${channelId}/edit`;
|
||||
|
|
@ -192,27 +169,50 @@ function VideosPage() {
|
|||
</Button>
|
||||
</div>;
|
||||
|
||||
const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView(
|
||||
videos,
|
||||
activePage,
|
||||
totalPages,
|
||||
selectElm,
|
||||
selectedVideos,
|
||||
onSelect,
|
||||
setPage,
|
||||
);
|
||||
|
||||
const searchInput = <SearchInput clearable searchStr={searchStr} onSubmit={setSearchStr} actionIcon='search'/>;
|
||||
|
||||
return <>
|
||||
{header}
|
||||
<FilesView
|
||||
files={videos}
|
||||
view={view}
|
||||
limit={limit}
|
||||
activePage={activePage}
|
||||
totalPages={totalPages}
|
||||
showView={true}
|
||||
showLimit={true}
|
||||
showSelect={true}
|
||||
onSelect={onSelect}
|
||||
selectedKeys={selectedVideos}
|
||||
selectElem={selectElm}
|
||||
setView={setView}
|
||||
setLimit={setLimit}
|
||||
setPage={setPage}
|
||||
menuColumnsCount={3}
|
||||
menuColumns={menuColumns}
|
||||
>
|
||||
</FilesView>
|
||||
<Media at='mobile'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={2}>{selectButton}</Grid.Column>
|
||||
<Grid.Column width={2}>{viewButton}</Grid.Column>
|
||||
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={6}><SortButton sorts={videoOrders}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={10}>{searchInput}</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
<Media greaterThanOrEqual='tablet'>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={1}>{selectButton}</Grid.Column>
|
||||
<Grid.Column width={1}>{viewButton}</Grid.Column>
|
||||
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
|
||||
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
|
||||
<Grid.Column width={5}><SortButton sorts={videoOrders}/></Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={8}>{searchInput}</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Media>
|
||||
{body}
|
||||
{paginator}
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {useContext, useEffect, useRef, useState} from "react";
|
||||
import {
|
||||
addTag,
|
||||
fetchDomains,
|
||||
fetchFilesProgress,
|
||||
filesSearch,
|
||||
|
|
@ -8,7 +7,6 @@ import {
|
|||
getChannel,
|
||||
getChannels,
|
||||
getDirectories,
|
||||
getDownloaders,
|
||||
getDownloads,
|
||||
getFiles,
|
||||
getInventory,
|
||||
|
|
@ -18,17 +16,17 @@ import {
|
|||
getThrottleStatus,
|
||||
getVideo,
|
||||
getVideosStatistics,
|
||||
killDownloads, removeTag,
|
||||
searchArchives,
|
||||
searchVideos,
|
||||
setHotspot,
|
||||
setThrottle,
|
||||
startDownloads
|
||||
} from "../api";
|
||||
import {createSearchParams, useSearchParams} from "react-router-dom";
|
||||
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
|
||||
import {StatusContext} from "../contexts/contexts";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
import {Modal} from "semantic-ui-react";
|
||||
import {Button} from "../components/Theme";
|
||||
|
||||
const calculatePage = (offset, limit) => {
|
||||
return offset && limit ? Math.round((offset / limit) + 1) : 1;
|
||||
|
|
@ -128,6 +126,17 @@ export const useQuery = () => {
|
|||
return {searchParams, setSearchParams, setQuery, updateQuery}
|
||||
}
|
||||
|
||||
export const useOneQuery = (name) => {
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const value = searchParams.get(name);
|
||||
|
||||
const setValue = (newValue) => {
|
||||
updateQuery({[name]: newValue});
|
||||
}
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
export const usePages = (defaultLimit) => {
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const offset = searchParams.get('o') || 0;
|
||||
|
|
@ -148,11 +157,12 @@ export const usePages = (defaultLimit) => {
|
|||
return {offset, limit, setLimit, activePage, setPage};
|
||||
}
|
||||
|
||||
export const useSearchArchives = (defaultLimit, domain, order_by) => {
|
||||
const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit)
|
||||
export const useSearchArchives = (defaultLimit) => {
|
||||
const {domain} = useSearchDomain();
|
||||
const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit);
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const searchStr = searchParams.get('q') || '';
|
||||
const order = searchParams.get('order') || order_by;
|
||||
const order = searchParams.get('order');
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
|
||||
const [archives, setArchives] = useState(null);
|
||||
|
|
@ -208,7 +218,7 @@ export const useSearchArchives = (defaultLimit, domain, order_by) => {
|
|||
|
||||
export const useSearchVideos = (defaultLimit, channelId, order_by) => {
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit)
|
||||
const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit);
|
||||
const searchStr = searchParams.get('q') || '';
|
||||
const order = searchParams.get('order') || order_by;
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
|
|
@ -724,3 +734,24 @@ export const useStatistics = () => {
|
|||
|
||||
return {statistics};
|
||||
}
|
||||
|
||||
export const useSearchFilter = () => {
|
||||
const [filter, setFilter] = useOneQuery('filter');
|
||||
return {filter, setFilter}
|
||||
}
|
||||
|
||||
export const useSearchDomain = () => {
|
||||
const [domains] = useDomains();
|
||||
const [domain, setDomain] = useOneQuery('domain');
|
||||
return {domain, domains, setDomain}
|
||||
}
|
||||
|
||||
export const useSearchView = () => {
|
||||
const [view, setView] = useOneQuery('view');
|
||||
return {view, setView}
|
||||
}
|
||||
|
||||
export const useSearchOrder = () => {
|
||||
const [order, setOrder] = useOneQuery('order');
|
||||
return {sort: order, setSort: setOrder}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ ARCHIVE_ORDERS = {
|
|||
'-date': 'a.archive_datetime DESC NULLS LAST, LOWER(fg.primary_path) DESC',
|
||||
'rank': '2 DESC, a.archive_datetime DESC',
|
||||
'-rank': '2 ASC, a.archive_datetime ASC',
|
||||
'size': 'fg.size ASC, LOWER(fg.primary_path) ASC',
|
||||
'-size': 'fg.size DESC NULLS LAST, LOWER(fg.primary_path) DESC',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ def test_get_channel_videos_pagination(test_client, test_session, simple_channel
|
|||
]
|
||||
last_ids = []
|
||||
for offset, video_count in tests:
|
||||
_, response = assert_video_search(channel_id=simple_channel.id, order_by='id', offset=offset, limit=20)
|
||||
_, response = assert_video_search(channel_id=simple_channel.id, order_by='upload_date', offset=offset, limit=20)
|
||||
assert len(response.json['file_groups']) == video_count, 'Returned videos does not match'
|
||||
current_ids = [i['id'] for i in response.json['file_groups']]
|
||||
assert current_ids != last_ids, f'IDs are unchanged current_ids={current_ids}'
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ VIDEO_ORDERS = {
|
|||
'-upload_date': 'v.upload_date DESC NULLS LAST, LOWER(fg.primary_path) ASC',
|
||||
'rank': '2 DESC, LOWER(fg.primary_path) DESC',
|
||||
'-rank': '2 ASC, LOWER(fg.primary_path) ASC',
|
||||
'id': 'fg.id ASC',
|
||||
'-id': 'fg.id DESC',
|
||||
'size': 'fg.size ASC, LOWER(fg.primary_path) ASC',
|
||||
'-size': 'fg.size DESC, LOWER(fg.primary_path) DESC',
|
||||
'duration': 'duration ASC, LOWER(fg.primary_path) ASC',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from uuid import uuid1, uuid4
|
|||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
import yaml
|
||||
from PIL import Image
|
||||
from sanic_testing.testing import SanicTestClient
|
||||
from sqlalchemy.engine import Engine, create_engine
|
||||
|
|
@ -561,3 +562,21 @@ def test_tags_config(test_directory):
|
|||
(test_directory / 'config').mkdir(exist_ok=True)
|
||||
config_path = test_directory / 'config/tags.yaml'
|
||||
yield config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_tags_config(test_tags_config):
|
||||
def _(tags: dict = None):
|
||||
from wrolpi.test.common import assert_dict_contains
|
||||
with test_tags_config.open('rt') as fh:
|
||||
contents = yaml.load(fh, Loader=yaml.Loader)
|
||||
|
||||
if tags:
|
||||
for key, item in tags.items():
|
||||
assert key in contents['tags']
|
||||
assert_dict_contains(contents['tags'][key], item)
|
||||
else:
|
||||
# Expect no tags to be saved.
|
||||
assert not contents['tags']
|
||||
|
||||
return _
|
||||
|
|
|
|||
|
|
@ -146,8 +146,11 @@ def _mimetype_suffix_map(path: Path, mimetype: str):
|
|||
"""Special handling for mimetypes. Python Magic may not return the correct mimetype."""
|
||||
from wrolpi.files.ebooks import MOBI_MIMETYPE
|
||||
suffix = path.suffix.lower()
|
||||
if mimetype == 'application/octet-stream' and suffix.endswith('.mobi'):
|
||||
return MOBI_MIMETYPE
|
||||
if mimetype == 'application/octet-stream':
|
||||
if suffix.endswith('.mobi'):
|
||||
return MOBI_MIMETYPE
|
||||
if suffix.endswith('.stl'):
|
||||
return 'model/stl'
|
||||
if suffix.endswith('.hgt'):
|
||||
return 'application/octet-stream'
|
||||
if mimetype == 'text/plain':
|
||||
|
|
@ -173,6 +176,8 @@ def _mimetype_suffix_map(path: Path, mimetype: str):
|
|||
return 'text/yaml'
|
||||
if suffix.endswith('.azw3'):
|
||||
return 'application/vnd.amazon.mobi8-ebook'
|
||||
if suffix.endswith('.obj'):
|
||||
return 'model/obj'
|
||||
return mimetype
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -119,16 +119,16 @@ class TagsConfig(ConfigFile):
|
|||
def save_tags(self, session: Session):
|
||||
media_directory = get_media_directory()
|
||||
|
||||
from wrolpi.files.models import FileGroup
|
||||
results = session.query(Tag, TagFile, FileGroup) \
|
||||
.filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \
|
||||
.order_by(FileGroup.primary_path)
|
||||
|
||||
tags = dict()
|
||||
tag_rows = session.query(Tag)
|
||||
for tag in tag_rows:
|
||||
tags[tag.name] = dict(color=tag.color)
|
||||
|
||||
from wrolpi.files.models import FileGroup
|
||||
results = session.query(Tag, TagFile, FileGroup) \
|
||||
.filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \
|
||||
.order_by(FileGroup.primary_path)
|
||||
|
||||
tag_files = []
|
||||
for tag, _, file_group in results:
|
||||
value = [tag.name, str(file_group.primary_path.relative_to(media_directory))]
|
||||
|
|
@ -204,6 +204,8 @@ def upsert_tag(name: str, color: str, tag_id: int = None, session: Session = Non
|
|||
session.rollback()
|
||||
raise InvalidTag(f'Name already taken') from e
|
||||
|
||||
schedule_save()
|
||||
|
||||
return tag
|
||||
|
||||
|
||||
|
|
@ -221,6 +223,8 @@ def delete_tag(tag_id: int, session: Session = None):
|
|||
session.delete(tag)
|
||||
session.commit()
|
||||
|
||||
schedule_save()
|
||||
|
||||
|
||||
@optional_session
|
||||
def import_tags_config(session: Session = None):
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ async def test_tags_config_(test_session, test_directory, tag_factory, example_p
|
|||
video.remove_tag(tag1)
|
||||
|
||||
|
||||
def test_tags_crud(test_session, test_client, example_pdf):
|
||||
def test_tags_crud(test_session, test_client, example_pdf, assert_tags_config):
|
||||
"""Test API Create/Retrieve/Update/Delete of Tags."""
|
||||
pdf = FileGroup.from_paths(test_session, example_pdf)
|
||||
test_session.add(pdf)
|
||||
|
|
@ -121,6 +121,7 @@ def test_tags_crud(test_session, test_client, example_pdf):
|
|||
content = dict(name='foo', color='#123456')
|
||||
request, response = test_client.post('/api/tag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert_tags_config(tags={'foo': {'color': '#123456'}})
|
||||
|
||||
# The tag can be retrieved.
|
||||
request, response = test_client.get('/api/tag')
|
||||
|
|
@ -138,11 +139,13 @@ def test_tags_crud(test_session, test_client, example_pdf):
|
|||
assert response.status_code == HTTPStatus.OK
|
||||
request, response = test_client.get('/api/tag')
|
||||
assert response.json['tags'] == [dict(name='bar', color='#000000', id=1)]
|
||||
assert_tags_config(tags={'bar': {'color': '#000000'}})
|
||||
|
||||
# Conflicting names return an error.
|
||||
content = dict(name='bar', color='#111111')
|
||||
request, response = test_client.post('/api/tag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert_tags_config(tags={'bar': {'color': '#000000'}})
|
||||
|
||||
# Cannot delete Tag that is used.
|
||||
request, response = test_client.delete('/api/tag/1')
|
||||
|
|
@ -155,6 +158,9 @@ def test_tags_crud(test_session, test_client, example_pdf):
|
|||
request, response = test_client.delete('/api/tag/1')
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Config is empty.
|
||||
assert_tags_config()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_tagged_file(test_session, example_pdf, tag_factory):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue