Creating sort button which allows the user to select a sort order via modal.

This commit is contained in:
Roland 2023-04-09 14:13:36 -06:00
parent 3b3cea4d70
commit bab10a0c96
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
14 changed files with 531 additions and 263 deletions

View file

@ -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/>}
</>;
}

View file

@ -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}

View file

@ -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>

View file

@ -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>
</>
}

View file

@ -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() {

View file

@ -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}
</>
}

View file

@ -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}
}

View file

@ -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',
}

View file

@ -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}'

View file

@ -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',

View file

@ -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 _

View file

@ -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

View file

@ -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):

View file

@ -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):