diff --git a/app/src/App.js b/app/src/App.js index 4f2a4200..e16cf18d 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -3,7 +3,7 @@ import './App.css'; import {NavBar} from "./components/Nav"; import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, RouterProvider} from "react-router-dom"; import {VideosRoute, VideoWrapper} from "./components/Videos"; -import Admin from "./components/admin/Admin"; +import AdminRoute from "./components/admin/AdminRoute"; import {Container} from "semantic-ui-react"; import 'semantic-ui-offline/semantic.min.css'; import {MoreRoute} from "./components/Apps"; @@ -14,8 +14,8 @@ import {StatusProvider} from "./hooks/customHooks"; import {MapRoute} from "./components/Map"; import {MediaContextProvider, mediaStyles, StatusContext, ThemeContext} from "./contexts/contexts"; import {Header, ThemeProvider} from "./components/Theme"; -import {Dashboard} from "./Dashboard"; -import {Donate} from "./components/Donate"; +import {DashboardPage} from "./DashboardPage"; +import {DonatePage} from "./components/DonatePage"; import {useEventsInterval} from "./Events"; import {SemanticToastContainer} from "react-semantic-toasts-2"; import {FilePreviewProvider} from "./components/FilePreview"; @@ -63,13 +63,13 @@ const router = createBrowserRouter(createRoutesFromElements(} errorElement={} > - }/> - }/> - }/> + }/> + }/> + }/> }/> }/> }/> - }/> + }/> }/> }/> }/> diff --git a/app/src/Dashboard.js b/app/src/DashboardPage.js similarity index 97% rename from app/src/Dashboard.js rename to app/src/DashboardPage.js index fe5077be..6bc89ace 100644 --- a/app/src/Dashboard.js +++ b/app/src/DashboardPage.js @@ -24,7 +24,7 @@ import {TagsDashboard} from "./Tags"; import {Upload} from "./components/Upload"; import {SearchView, useSearch} from "./components/Search"; import {KiwixRestartMessage, OutdatedZimsMessage} from "./components/Zim"; -import {useSettings} from "./hooks/customHooks"; +import {useSettings, useWROLMode} from "./hooks/customHooks"; function FlagsMessages({flags}) { const {settings, fetchSettings} = useSettings(); @@ -82,13 +82,14 @@ function FlagsMessages({flags}) { } -export function Dashboard() { +export function DashboardPage() { useTitle('Dashboard'); + const navigate = useNavigate(); + const {searchStr, setSearchStr, activeTags} = useSearch(); const {status} = useContext(StatusContext); - const navigate = useNavigate(); - const wrol_mode = status ? status['wrol_mode'] : null; + const wrolModeEnabled = useWROLMode(); // Getters are Downloads or Uploads. const [selectedGetter, setSelectedGetter] = useState(null); @@ -150,7 +151,7 @@ export function Dashboard() { body = ; } else { body = <> - {!wrol_mode && getter} + {!wrolModeEnabled && getter} {getterModal} diff --git a/app/src/Tags.js b/app/src/Tags.js index 3fb3989f..791277bf 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -1,10 +1,8 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect} from "react"; import {deleteTag, getTags, saveTag} from "./api"; import { - Confirm, Dimmer, Divider, - Dropdown, Form, Grid, Label, @@ -15,7 +13,7 @@ import { TableHeaderCell, TableRow, } from "semantic-ui-react"; -import {contrastingColor, scrollToTopOfElement} from "./components/Common"; +import {APIButton, contrastingColor, scrollToTopOfElement} from "./components/Common"; import { Button, FormInput, @@ -150,18 +148,15 @@ export const useTagsInterval = () => { function EditTagRow({tag, onDelete, onEdit}) { const {NameToTagLabel} = React.useContext(TagsContext); - const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const {name, color, id, count} = tag; const deleteConfirm = <> - + Save diff --git a/app/src/api.js b/app/src/api.js index c6451f66..ff39571a 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -82,7 +82,13 @@ export async function createChannel(channel) { } export async function deleteChannel(channelId) { - return apiDelete(`${VIDEOS_API}/channels/${channelId}`); + const response = await apiDelete(`${VIDEOS_API}/channels/${channelId}`); + if (response.status !== 204) { + const content = await response.json(); + console.error(content); + throw Error(content); + } + return response; } export async function getChannels() { @@ -155,16 +161,13 @@ export async function deleteVideos(videoIds) { time: 5000, }); } - console.debug(`Deleting Videos: ${videoIds}`); + console.info(`Deleting Videos: ${videoIds}`); const i = videoIds.join(','); let response = await apiDelete(`${VIDEOS_API}/video/${i}`); if (response.status !== 204) { - toast({ - type: 'error', - title: 'Unexpected server response', - description: 'Failed to delete videos. See server logs.', - time: 5000, - }); + const content = response.json(); + console.error(content); + throw Error(content); } } @@ -249,7 +252,7 @@ export async function downloadChannel(channelId) { export async function refreshChannel(channelId) { let url = `${VIDEOS_API}/channels/refresh/${channelId}`; - await fetch(url, {method: 'POST'}); + return await fetch(url, {method: 'POST'}); } export async function encryptOTP(otp, plaintext) { @@ -361,15 +364,13 @@ export async function deleteArchives(archiveIds) { time: 5000, }); } - console.debug(`Deleting Archives: ${archiveIds}`); + console.log(`Deleting Archives: ${archiveIds}`); let i = archiveIds.join(','); - try { - return await apiDelete(`${API_URI}/archive/${i}`); - } catch (e) { - console.error(e); - toast({ - type: 'error', title: 'Unexpected server response', description: 'Unable to delete archives', time: 5000, - }); + const response = await apiDelete(`${API_URI}/archive/${i}`); + if (response.status !== 204) { + const content = await response.json(); + console.debug(content); + throw Error(content['error']); } } @@ -462,7 +463,11 @@ export async function postDownload(urls, downloader, frequency, sub_downloader, body['tag_names'] = tagNames; } let response = await apiPost(`${API_URI}/download`, body); - return response; + if (response.status !== 204) { + const content = await response.json(); + console.error(content); + throw Error(content['error']); + } } export async function killDownload(download_id) { diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js index e6936ecd..058f5b90 100644 --- a/app/src/components/Apps.js +++ b/app/src/components/Apps.js @@ -2,7 +2,7 @@ import React, {useContext} from 'react'; import {Divider, Input, SegmentGroup, StatisticLabel, StatisticValue, TableCell, TableRow} from "semantic-ui-react"; import {Link, Route, Routes} from "react-router-dom"; import {decryptOTP, encryptOTP} from "../api"; -import {humanFileSize, mimetypeColor, PageContainer, toLocaleString, useTitle} from "./Common"; +import {APIButton, humanFileSize, mimetypeColor, PageContainer, toLocaleString, useTitle} from "./Common"; import {ThemeContext} from "../contexts/contexts"; import {Button, Header, Loader, Segment, Statistic, StatisticGroup, Table, TextArea} from "./Theme"; import {useStatistics, useVINDecoder} from "../hooks/customHooks"; @@ -15,14 +15,9 @@ class Encrypt extends React.Component { } } - handleSubmit = async (e) => { - e.preventDefault(); - try { - let {otp, plaintext, ciphertext} = await encryptOTP(this.state.otp, this.state.plaintext); - this.setState({otp, plaintext, ciphertext}); - } catch (e) { - console.error(e); - } + handleSubmit = async () => { + let {otp, plaintext, ciphertext} = await encryptOTP(this.state.otp, this.state.plaintext); + this.setState({otp, plaintext, ciphertext}); } handleInputChange = async (event, {name, value}) => { @@ -30,6 +25,8 @@ class Encrypt extends React.Component { } render() { + const disabled = !(this.state.otp && this.state.plaintext); + return <>
Encrypt
@@ -59,7 +56,10 @@ class Encrypt extends React.Component {
- + Encrypt } } @@ -68,18 +68,13 @@ class Decrypt extends React.Component { constructor(props) { super(props); this.state = { - otp: '', plaintext: '', ciphertext: '', + otp: '', plaintext: '', ciphertext: '' } } - handleSubmit = async (e) => { - e.preventDefault(); - try { - let {otp, plaintext, ciphertext} = await decryptOTP(this.state.otp, this.state.ciphertext); - this.setState({otp, plaintext, ciphertext}); - } catch (e) { - console.error(e); - } + handleSubmit = async () => { + let {otp, plaintext, ciphertext} = await decryptOTP(this.state.otp, this.state.ciphertext); + this.setState({otp, plaintext, ciphertext}); } handleInputChange = async (event, {name, value}) => { @@ -87,6 +82,8 @@ class Decrypt extends React.Component { } render() { + const disabled = !(this.state.otp && this.state.ciphertext); + return <>
Decrypt
@@ -115,8 +112,13 @@ class Decrypt extends React.Component {
{this.state.plaintext || 'Enter the encrypted message above'}
+
- + + Decrypt } } diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index f22ab514..a314419e 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -15,6 +15,7 @@ import { TableRow } from "semantic-ui-react"; import { + APIButton, BackButton, CardLink, cardTitleWrapper, @@ -45,10 +46,9 @@ 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"; +import {toast} from "react-semantic-toasts-2"; function ArchivePage() { - const [deleteOpen, setDeleteOpen] = useState(false); - const [updateOpen, setUpdateOpen] = useState(false); const navigate = useNavigate(); const {archiveId} = useParams(); const {archiveFile, history, fetchArchive} = useArchive(archiveId); @@ -88,35 +88,41 @@ function ArchivePage() { null; const localDeleteArchive = async () => { - setDeleteOpen(false); await deleteArchives([data.id]); + toast({ + type: 'success', + title: 'Archive Deleted', + description: 'Archive was successfully deleted.', + time: 2000, + }) navigate(-1); } const localUpdateArchive = async () => { - setUpdateOpen(false); await postDownload(data.url, 'archive'); + toast({ + type: 'success', + title: 'Archive Downloading', + description: 'Archive update has been scheduled.', + time: 2000, + }) } - const updateButton = (<> - - setDeleteOpen(false)} - onConfirm={handleDeleteConfirm} - /> - - + obeyWROLMode={true} + >Download + Refresh } - + onClick={handleSubmit} + disabled={disabled} + obeyWROLMode={true} + >Save diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 386abef0..44a0915e 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,5 +1,17 @@ import React, {useContext, useEffect, useState} from "react"; -import {ButtonGroup, Card, Container, IconGroup, Input, Label, Pagination, Search} from 'semantic-ui-react'; +import { + Button as SButton, + ButtonGroup, + Card, + Confirm, + Container, + IconGroup, + Input, + Label, + Pagination, + Search, + Transition +} from 'semantic-ui-react'; import {Link, NavLink, useNavigate, useSearchParams} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import { @@ -8,7 +20,8 @@ import { useSearchDirectories, useSearchOrder, useSettings, - useThrottle + useThrottle, + useWROLMode } from "../hooks/customHooks"; import {Media, StatusContext, ThemeContext} from "../contexts/contexts"; import { @@ -419,10 +432,9 @@ export function SearchInput({ } export function WROLModeMessage({content}) { - const {status} = useContext(StatusContext); - const wrol_mode = status ? status.wrol_mode : null; + const wrolModeEnabled = useWROLMode(); - if (wrol_mode) { + if (wrolModeEnabled) { return } return null; @@ -674,6 +686,7 @@ export function DisableDownloadsToggle() { const {status, fetchStatus} = React.useContext(StatusContext); const {downloads} = status ? status : {downloads: null}; + const wrolModeEnabled = useWROLMode(); const setDownloads = async (enable) => { setPending(true); @@ -690,7 +703,7 @@ export function DisableDownloadsToggle() { return @@ -1190,4 +1203,166 @@ export function TagIcon() { export function normalizeEstimate(estimate) { return estimate > 999 ? '>999' : estimate; +} + +export function useAPIButton( + color = 'violet', + size = 'medium', + floated, + onClick, + disabled, + confirmContent, + confirmButton, + themed = true, + obeyWROLMode = false, + icon = null, + props +) { + props = props || {}; + const ref = React.useRef(); + + const [confirmOpen, setConfirmOpen] = React.useState(false); + const [pending, setPending] = React.useState(false); + const [animation, setAnimation] = React.useState('jiggle'); + const [animationVisible, setAnimationVisible] = React.useState(true); + const [showSuccess, setShowSuccess] = React.useState(false); + const [showFailure, setShowFailure] = React.useState(false); + + const wrolModeEnabled = useWROLMode(); + + // Disable when API call is pending, or button is disabled. + disabled = pending || disabled; + // Disable when WROL Mode is enabled, otherwise normal disabled. + disabled = obeyWROLMode ? wrolModeEnabled || disabled : disabled; + + const reset = () => { + setShowSuccess(false); + setShowFailure(false); + }; + + const setSuccess = () => { + setShowSuccess(true); + setAnimation('pulse'); + setAnimationVisible(!animationVisible); + setTimeout(reset, 2000); + } + + const setFailure = () => { + setShowFailure(true); + setAnimation('shake'); + setAnimationVisible(!animationVisible); + setTimeout(reset, 2000); + } + + const handleAPICall = async () => { + // Handle when user clicks button, or clicks confirm. + setPending(true); + try { + await onClick(); + setSuccess(); + } catch (e) { + console.log(e); + setFailure(); + } + setPending(false); + } + + const localOnClick = async (e) => { + if (e) { + e.preventDefault(); + } + + if (confirmContent) { + // Clicking button opens confirm. + setConfirmOpen(true); + } else if (onClick) { + // No send the API request. + await handleAPICall(); + } else { + throw Error('No onClick defined!'); + } + } + + const localOnConfirm = async () => { + // User clicked the "OK" button in the . + setConfirmOpen(false); + + if (onClick) { + await handleAPICall(); + } + } + + // Create button with or without theme. Pass all props to the + : {buttonContent}; + // Wrap button in to show success or failure animations. + button = + {button} + ; + + if (confirmContent) { + // Wrap button with + button = <> + {button} + setConfirmOpen(false)} + onCancel={() => setConfirmOpen(false)} + onConfirm={localOnConfirm} + /> + + } + + button = <> + {button} + + + return {button, ref} +} + +export function APIButton({ + color, + size, + floated, + onClick, + disabled, + confirmContent, + confirmButton, + themed, + obeyWROLMode, + icon, + ...props + }) { + const {button} = useAPIButton( + color, + size, + floated, + onClick, + disabled, + confirmContent, + confirmButton, + themed, + obeyWROLMode, + icon, + props + ); + + return button; } \ No newline at end of file diff --git a/app/src/components/Donate.js b/app/src/components/DonatePage.js similarity index 98% rename from app/src/components/Donate.js rename to app/src/components/DonatePage.js index fd574ee0..dba75813 100644 --- a/app/src/components/Donate.js +++ b/app/src/components/DonatePage.js @@ -54,7 +54,7 @@ function CoinRow({header, qrCodeValue, address, buttonColor}) { } -export function Donate() { +export function DonatePage() { useTitle('Donate'); return diff --git a/app/src/components/Download.js b/app/src/components/Download.js index 12287cf1..29341cde 100644 --- a/app/src/components/Download.js +++ b/app/src/components/Download.js @@ -1,6 +1,6 @@ import React, {useContext, useState} from "react"; import {getDownloaders, postDownload} from "../api"; -import {DirectorySearch, frequencyOptions, HelpPopup, rssFrequencyOptions, WROLModeMessage} from "./Common"; +import {APIButton, DirectorySearch, frequencyOptions, HelpPopup, rssFrequencyOptions, WROLModeMessage} from "./Common"; import Icon from "semantic-ui-react/dist/commonjs/elements/Icon"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {ThemeContext} from "../contexts/contexts"; @@ -146,7 +146,11 @@ class Downloader extends React.Component { {withSearchDirectory ? destinationRequired ? directorySearch : advancedAccordion : null} + Download {submitted && viewDownloads} )} @@ -230,12 +234,10 @@ class ChannelDownload extends React.Component { {frequency === 0 && onceMessage} ; @@ -599,6 +601,7 @@ export function DirectoryRefreshButton({paths}) { const {status} = useContext(StatusContext); const refreshing = status && status['flags'] && status['flags'].indexOf('refreshing') >= 0; const refreshingDirectory = status && status['flags'] && status['flags'].indexOf('refreshing_directory') >= 0; + const wrolModeEnabled = useWROLMode(); const [loading, setLoading] = React.useState(false); @@ -615,7 +618,7 @@ export function DirectoryRefreshButton({paths}) { labelPosition='left' loading={loading || refreshing || refreshingDirectory} onClick={handleClick} - disabled={loading || refreshing || refreshingDirectory} + disabled={wrolModeEnabled || loading || refreshing || refreshingDirectory} > Refresh diff --git a/app/src/components/Map.js b/app/src/components/Map.js index 9d44e7e0..fa1a9c2f 100644 --- a/app/src/components/Map.js +++ b/app/src/components/Map.js @@ -267,7 +267,7 @@ class ManageMap extends React.Component { } } -function MapApp() { +function MapPage() { return