Feature/swr for simple queries (#187)

* Refactor simple queries with useSWR hook

* Fix plus button in Extensions screen

* Add type to categories query

* Add useQuery abstraction which adds loading attribute

* Reload categories even if reorder fails
This commit is contained in:
Valter Martinek
2022-11-02 21:48:22 +01:00
committed by GitHub
parent 6f8755fa05
commit 14265164a4
11 changed files with 134 additions and 182 deletions

View File

@@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useEffect, useState } from 'react';
import React, { useMemo } from 'react';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
@@ -14,7 +14,7 @@ import Dialog from '@mui/material/Dialog';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import client from 'util/client';
import client, { useQuery } from 'util/client';
interface IProps {
open: boolean
@@ -22,37 +22,21 @@ interface IProps {
mangaId: number
}
interface ICategoryInfo {
category: ICategory
selected: boolean
}
export default function CategorySelect(props: IProps) {
const { open, setOpen, mangaId } = props;
const [categoryInfos, setCategoryInfos] = useState<ICategoryInfo[]>([]);
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
const { data: mangaCategoriesData, mutate } = useQuery<ICategory[]>(`/api/v1/manga/${mangaId}/category`);
const { data: categoriesData } = useQuery<ICategory[]>('/api/v1/category');
useEffect(() => {
let tmpCategoryInfos: ICategoryInfo[] = [];
client.get('/api/v1/category/')
.then((response) => response.data)
.then((data: ICategory[]) => {
if (data.length > 0 && data[0].name === 'Default') { data.shift(); }
tmpCategoryInfos = data.map((category) => ({ category, selected: false }));
})
.then(() => {
client.get(`/api/v1/manga/${mangaId}/category/`)
.then((response) => response.data)
.then((data: ICategory[]) => {
data.forEach((category) => {
tmpCategoryInfos[category.order - 1].selected = true;
});
setCategoryInfos(tmpCategoryInfos);
});
});
}, [updateTriggerHolder, open]);
const allCategories = useMemo(() => {
const cats = [...(categoriesData ?? [])]; // make copy
if (cats.length > 0 && cats[0].name === 'Default') {
cats.shift(); // remove first category if it is 'Default'
}
return cats;
}, [categoriesData]);
const selectedIds = mangaCategoriesData?.map((c) => c.id) ?? [];
const handleCancel = () => {
setOpen(false);
@@ -67,7 +51,7 @@ export default function CategorySelect(props: IProps) {
const method = checked ? client.get : client.delete;
method(`/api/v1/manga/${mangaId}/category/${categoryId}`)
.then(() => triggerUpdate());
.then(() => mutate());
};
return (
@@ -84,7 +68,7 @@ export default function CategorySelect(props: IProps) {
<DialogTitle>Set categories</DialogTitle>
<DialogContent dividers>
<FormGroup>
{categoryInfos.length === 0
{allCategories.length === 0
&& (
<span>
No categories found!
@@ -92,17 +76,17 @@ export default function CategorySelect(props: IProps) {
You should make some from settings.
</span>
)}
{categoryInfos.map((categoryInfo) => (
{allCategories.map((category) => (
<FormControlLabel
control={(
<Checkbox
checked={categoryInfo.selected}
onChange={(e) => handleChange(e, categoryInfo.category.id)}
checked={selectedIds.includes(category.id)}
onChange={(e) => handleChange(e, category.id)}
color="default"
/>
)}
label={categoryInfo.category.name}
key={categoryInfo.category.id}
label={category.name}
key={category.id}
/>
))}
</FormGroup>

View File

@@ -9,7 +9,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import NavbarContext from 'components/context/NavbarContext';
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import IconButton from '@mui/material/IconButton';
@@ -28,8 +28,7 @@ import EmptyView from 'components/util/EmptyView';
import { Box } from '@mui/system';
import Typography from '@mui/material/Typography';
import { useHistory } from 'react-router-dom';
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
import useSubscription from 'components/library/useSubscription';
const getItemStyle = (isDragging: boolean,
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
@@ -47,9 +46,8 @@ const initialQueue = {
} as IQueue;
export default function DownloadQueue() {
const [, setWsClient] = useState<WebSocket>();
const [queueState, setQueueState] = useState<IQueue>(initialQueue);
const { queue, status } = queueState;
const { data: queueState } = useSubscription<IQueue>('/api/v1/downloads');
const { queue, status } = queueState ?? initialQueue;
const history = useHistory();
@@ -84,15 +82,6 @@ export default function DownloadQueue() {
});
}, [status]);
useEffect(() => {
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
wsc.onmessage = (e) => {
setQueueState(JSON.parse(e.data));
};
setWsClient(wsc);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onDragEnd = (result: DropResult) => {
};

View File

@@ -5,13 +5,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useContext, useEffect, useState } from 'react';
import React, {
useContext, useEffect, useState, useMemo, useRef,
} from 'react';
import { fromEvent } from 'file-selector';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import ExtensionCard from 'components/ExtensionCard';
import NavbarContext from 'components/context/NavbarContext';
import client from 'util/client';
import client, { useQuery } from 'util/client';
import useLocalStorage from 'util/useLocalStorage';
import LangSelect from 'components/navbar/action/LangSelect';
import { extensionDefaultLangs, langCodeToName, langSortCmp } from 'util/language';
@@ -63,6 +65,7 @@ function groupExtensions(extensions: IExtension[]) {
}
export default function MangaExtensions() {
const inputRef = useRef<HTMLInputElement>(null);
const { setTitle, setAction } = useContext(NavbarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
@@ -76,9 +79,7 @@ export default function MangaExtensions() {
<>
<AppbarSearch />
<IconButton
onClick={
() => document.getElementById('external-extension-file')?.click()
}
onClick={() => inputRef.current?.click()}
size="large"
>
<AddIcon />
@@ -92,16 +93,19 @@ export default function MangaExtensions() {
);
}, [shownLangs]);
const [extensionsRaw, setExtensionsRaw] = useState<IExtension[]>([]);
const { data: allExtensions, mutate, loading } = useQuery<IExtension[]>('/api/v1/extension/list');
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
const filteredExtensions = useMemo(() => (allExtensions ?? []).filter((ext) => {
const nsfwFilter = showNsfw || !ext.isNsfw;
if (!query) return nsfwFilter;
return nsfwFilter && ext.name.toLowerCase().includes(query.toLowerCase());
}), [allExtensions, showNsfw, query]);
useEffect(() => {
client.get('/api/v1/extension/list')
.then((response) => response.data)
.then((data) => setExtensionsRaw(data));
}, [updateTriggerHolder]);
const groupedExtensions = useMemo(() => groupExtensions(filteredExtensions)
.filter((group) => group[EXTENSIONS].length > 0)
.filter((group) => ['installed', 'updates pending', 'all', ...shownLangs].includes(group[LANGUAGE])), [shownLangs, filteredExtensions]);
const flatRenderItems: (IExtension | string)[] = groupedExtensions.flat(2);
const [toasts, makeToast] = makeToaster(useState<React.ReactElement[]>([]));
@@ -110,16 +114,16 @@ export default function MangaExtensions() {
const formData = new FormData();
formData.append('file', file);
// empty the input
// @ts-ignore
document.getElementById('external-extension-file').value = null;
if (inputRef.current) {
inputRef.current.value = '';
}
makeToast('Installing Extension File....', 'info');
client.post('/api/v1/extension/install',
formData, { headers: { 'Content-Type': 'multipart/form-data' } })
.then(() => {
makeToast('Installed extension successfully!', 'success');
triggerUpdate();
mutate();
})
.catch(() => makeToast('Extension installion failed!', 'error'));
} else {
@@ -127,60 +131,43 @@ export default function MangaExtensions() {
}
};
const dropHandler = async (e: Event) => {
e.preventDefault();
const files = await fromEvent(e);
submitExternalExtension(files[0] as File);
};
const dragOverHandler = (e: Event) => {
e.preventDefault();
};
useEffect(() => {
document.addEventListener('drop', dropHandler);
document.addEventListener('dragover', dragOverHandler);
const changeHandler = async (evt: Event) => {
const files = await fromEvent(evt);
const dropHandler = async (e: Event) => {
e.preventDefault();
const files = await fromEvent(e);
submitExternalExtension(files[0] as File);
};
const input = document.getElementById('external-extension-file');
input?.addEventListener('change', changeHandler);
const dragOverHandler = (e: Event) => {
e.preventDefault();
};
document.addEventListener('drop', dropHandler);
document.addEventListener('dragover', dragOverHandler);
return () => {
document.removeEventListener('drop', dropHandler);
document.removeEventListener('dragover', dragOverHandler);
input?.removeEventListener('change', changeHandler);
};
}, [extensionsRaw]); // useEffect only after <input> renders
}, []);
if (extensionsRaw.length === 0) {
if (loading) {
return <LoadingPlaceholder />;
}
const filtered = extensionsRaw.filter((ext) => {
const nsfwFilter = showNsfw || !ext.isNsfw;
if (!query) return nsfwFilter;
return nsfwFilter && ext.name.toLowerCase().includes(query.toLowerCase());
});
const combinedShownLangs = ['installed', 'updates pending', 'all', ...shownLangs];
const groupedExtensions: [string, IExtension[]][] = groupExtensions(filtered)
.filter((group) => group[EXTENSIONS].length > 0)
.filter((group) => combinedShownLangs.includes(group[LANGUAGE]));
const flatRenderItems: (IExtension | string)[] = groupedExtensions.flat(2);
return (
<>
{toasts}
<input
type="file"
id="external-extension-file"
style={{ display: 'none' }}
ref={inputRef}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
submitExternalExtension(file);
}
}}
/>
<Virtuoso
style={{
@@ -214,7 +201,7 @@ export default function MangaExtensions() {
key={item.apkName}
extension={item}
notifyInstall={() => {
triggerUpdate();
mutate();
}}
/>
);

View File

@@ -17,17 +17,21 @@ import LibraryOptions from 'components/library/LibraryOptions';
import LibraryMangaGrid from 'components/library/LibraryMangaGrid';
import AppbarSearch from 'components/util/AppbarSearch';
import { useQueryParam, NumberParam } from 'use-query-params';
import useSWR from 'swr';
import { useQuery } from 'util/client';
import UpdateChecker from '../components/library/UpdateChecker';
export default function Library() {
const { data: tabsData, error: tabsError } = useSWR<ICategory[]>('/api/v1/category');
const { data: tabsData, error: tabsError, loading } = useQuery<ICategory[]>('/api/v1/category');
const tabs = tabsData ?? [];
const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam);
const activeTab = tabs.find((t) => t.order === tabSearchParam) ?? tabs[0];
const { data: mangaData, error: mangaError } = useSWR<IManga[]>(`/api/v1/category/${activeTab?.id}`, {
const {
data: mangaData,
error: mangaError,
loading: mangaLoading,
} = useQuery<IManga[]>(`/api/v1/category/${activeTab?.id}`, {
isPaused: () => activeTab == null,
});
const mangas = mangaData ?? [];
@@ -56,7 +60,7 @@ export default function Library() {
return <EmptyView message="Could not load categories" messageExtra={tabsError?.message ?? tabsError} />;
}
if (tabsData == null) {
if (loading) {
return <LoadingPlaceholder />;
}
@@ -72,7 +76,7 @@ export default function Library() {
lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum}
message="Your Library is empty"
isLoading={activeTab != null && mangaData == null && mangaError == null}
isLoading={activeTab != null && mangaLoading}
/>
);
}
@@ -110,7 +114,7 @@ export default function Library() {
lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum}
message="Category is Empty"
isLoading={mangaData == null}
isLoading={mangaLoading}
/>
))}
</TabPanel>

View File

@@ -8,11 +8,10 @@
import React, {
useCallback, useEffect, useContext, useState, useRef,
} from 'react';
import useSWR from 'swr';
import { Box } from '@mui/system';
import MangaDetails from 'components/MangaDetails';
import NavbarContext from 'components/context/NavbarContext';
import { fetcher } from 'util/client';
import { fetcher, useQuery } from 'util/client';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import ChapterList from 'components/chapter/ChapterList';
import { useParams } from 'react-router-dom';
@@ -24,8 +23,8 @@ export default function Manga() {
const { id } = useParams<{ id: string }>();
const autofetchedRef = useRef(false);
const { data: manga, error, mutate: mutateManga } = useSWR<IManga>(`/api/v1/manga/${id}/?onlineFetch=false`);
const { data: chaptersData, mutate: mutateChapters } = useSWR<IChapter[]>(`/api/v1/manga/${id}/chapters?onlineFetch=false`);
const { data: manga, error, mutate: mutateManga } = useQuery<IManga>(`/api/v1/manga/${id}/?onlineFetch=false`);
const { data: chaptersData, mutate: mutateChapters } = useQuery<IChapter[]>(`/api/v1/manga/${id}/chapters?onlineFetch=false`);
const [fetchingOnline, setFetchingOnline] = useState(false);
const fetchOnline = useCallback(async () => {

View File

@@ -5,10 +5,10 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import NavbarContext from 'components/context/NavbarContext';
import { useParams } from 'react-router-dom';
import client from 'util/client';
import client, { useQuery } from 'util/client';
import { SwitchPreferenceCompat, CheckBoxPreference } from 'components/sourceConfiguration/TwoStatePreference';
import ListPreference from 'components/sourceConfiguration/ListPreference';
import EditTextPreference from 'components/sourceConfiguration/EditTextPreference';
@@ -34,21 +34,12 @@ function getPrefComponent(type: string) {
}
export default function SourceConfigure() {
const [sourcePreferences, setSourcePreferences] = useState<SourcePreferences[]>([]);
const { setTitle, setAction } = useContext(NavbarContext);
const [updateTriggerHolder, setUpdateTriggerHolder] = useState<number>(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
useEffect(() => { setTitle('Source Configuration'); setAction(<></>); }, []);
const { sourceId } = useParams<{ sourceId: string }>();
useEffect(() => {
client.get(`/api/v1/source/${sourceId}/preferences`)
.then((response) => response.data)
.then((data) => setSourcePreferences(data));
}, [updateTriggerHolder]);
const { data: sourcePreferences = [], mutate } = useQuery<SourcePreferences[]>(`/api/v1/source/${sourceId}/preferences`);
const convertToString = (position: number, value: any): string => {
switch (sourcePreferences[position].props.defaultValueType) {
@@ -63,7 +54,7 @@ export default function SourceConfigure() {
(value: any) => {
client.post(`/api/v1/source/${sourceId}/preferences`,
JSON.stringify({ position, value: convertToString(position, value) }))
.then(() => triggerUpdate());
.then(() => mutate());
}
);

View File

@@ -5,11 +5,10 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import LangSelect from 'components/navbar/action/LangSelect';
import SourceCard from 'components/SourceCard';
import NavbarContext from 'components/context/NavbarContext';
import client from 'util/client';
import {
sourceDefualtLangs, sourceForcedDefaultLangs, langCodeToName, langSortCmp,
} from 'util/language';
@@ -18,6 +17,7 @@ import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import { IconButton } from '@mui/material';
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
import { useHistory } from 'react-router-dom';
import { useQuery } from 'util/client';
function sourceToLangList(sources: ISource[]) {
const result: string[] = [];
@@ -46,8 +46,7 @@ export default function Sources() {
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
const [sources, setSources] = useState<ISource[]>([]);
const [fetched, setFetched] = useState<boolean>(false);
const { data: sources, loading } = useQuery<ISource[]>('/api/v1/source/list');
const history = useHistory();
@@ -80,27 +79,23 @@ export default function Sources() {
<LangSelect
shownLangs={shownLangs}
setShownLangs={setShownLangs}
allLangs={sourceToLangList(sources)}
allLangs={sourceToLangList(sources ?? [])}
forcedLangs={sourceForcedDefaultLangs()}
/>
</>,
);
}, [shownLangs, sources]);
useEffect(() => {
client.get('/api/v1/source/list')
.then((response) => response.data)
.then((data) => { setSources(data); setFetched(true); });
}, []);
if (loading) return <LoadingPlaceholder />;
if (sources.length === 0) {
if (fetched) return (<h3>No sources found. Install Some Extensions first.</h3>);
return <LoadingPlaceholder />;
if (sources?.length === 0) {
return (<h3>No sources found. Install Some Extensions first.</h3>);
}
return (
<>
{/* eslint-disable-next-line max-len */}
{Object.entries(groupByLang(sources)).sort((a, b) => langSortCmp(a[0], b[0])).map(([lang, list]) => (
{Object.entries(groupByLang(sources ?? [])).sort((a, b) => langSortCmp(a[0], b[0])).map(([lang, list]) => (
shownLangs.indexOf(lang) !== -1 && (
<React.Fragment key={lang}>
<h1 key={lang} style={{ marginLeft: 25 }}>{langCodeToName(lang)}</h1>

View File

@@ -1,26 +1,18 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import client from 'util/client';
import ListItemLink from 'components/util/ListItemLink';
import NavbarContext from 'components/context/NavbarContext';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import { useQuery } from 'util/client';
export default function About() {
const { setTitle, setAction } = useContext(NavbarContext);
const [about, setAbout] = useState<IAbout>();
useEffect(() => { setTitle('About'); setAction(<></>); }, []);
useEffect(() => {
client.get('/api/v1/settings/about')
.then((response) => response.data)
.then((data:IAbout) => {
setAbout(data);
});
}, []);
const { data: about } = useQuery<IAbout>('/api/v1/settings/about');
if (about === undefined) {
return <LoadingPlaceholder />;

View File

@@ -10,10 +10,10 @@ import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import { fromEvent } from 'file-selector';
import client from 'util/client';
import makeToast from 'components/util/Toast';
import ListItemLink from '../../components/util/ListItemLink';
import NavbarContext from '../../components/context/NavbarContext';
import client from '../../util/client';
export default function Backup() {
const { setTitle, setAction } = useContext(NavbarContext);

View File

@@ -8,7 +8,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useState, useContext, useEffect } from 'react';
import React, {
useMemo, useState, useContext, useEffect,
} from 'react';
import {
List,
ListItem,
@@ -34,7 +36,7 @@ import DialogTitle from '@mui/material/DialogTitle';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import NavbarContext from 'components/context/NavbarContext';
import client from 'util/client';
import client, { useQuery } from 'util/client';
const getItemStyle = (isDragging: boolean,
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
@@ -50,37 +52,31 @@ export default function Categories() {
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Categories'); setAction(<></>); }, []);
const [categories, setCategories] = useState<ICategory[]>([]);
const { data, mutate } = useQuery<ICategory[]>('/api/v1/category/');
const categories = useMemo(() => {
const res = [...data ?? []];
if (res.length > 0 && res[0].name === 'Default') {
res.shift();
}
return res;
}, [data]);
const [categoryToEdit, setCategoryToEdit] = useState<number>(-1); // -1 means new category
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [dialogName, setDialogName] = useState<string>('');
const [dialogDefault, setDialogDefault] = useState<boolean>(false);
const theme = useTheme();
const [updateTriggerHolder, setUpdateTriggerHolder] = useState<number>(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
useEffect(() => {
if (!dialogOpen) {
client.get('/api/v1/category/')
.then((response) => response.data)
.then((data) => { if (data.length > 0 && data[0].name === 'Default') data.shift(); return data; })
.then((data) => setCategories(data));
}
}, [updateTriggerHolder]);
const categoryReorder = (list: ICategory[], from: number, to: number) => {
const newData = [...list];
const [removed] = newData.splice(from, 1);
newData.splice(to, 0, removed);
mutate(newData, { revalidate: false });
const formData = new FormData();
formData.append('from', `${from + 1}`);
formData.append('to', `${to + 1}`);
client.patch('/api/v1/category/reorder', formData)
.finally(() => triggerUpdate());
// also move it in local state to avoid jarring moving behviour...
const result = Array.from(list);
const [removed] = result.splice(from, 1);
result.splice(to, 0, removed);
return result;
client.patch('/api/v1/category/reorder', formData).finally(() => mutate());
};
const onDragEnd = (result: DropResult) => {
@@ -89,11 +85,11 @@ export default function Categories() {
return;
}
setCategories(categoryReorder(
categoryReorder(
categories,
result.source.index,
result.destination.index,
));
);
};
const resetDialog = () => {
@@ -127,18 +123,18 @@ export default function Categories() {
if (categoryToEdit === -1) {
client.post('/api/v1/category/', formData)
.finally(() => triggerUpdate());
.finally(() => mutate());
} else {
const category = categories[categoryToEdit];
client.patch(`/api/v1/category/${category.id}`, formData)
.finally(() => triggerUpdate());
.finally(() => mutate());
}
};
const deleteCategory = (index:number) => {
const category = categories[index];
client.delete(`/api/v1/category/${category.id}`)
.finally(() => triggerUpdate());
.finally(() => mutate());
};
return (

View File

@@ -6,6 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import axios from 'axios';
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
import storage from './localStorage';
const { hostname, port, protocol } = window.location;
@@ -40,3 +41,17 @@ export async function fetcher<T = any>(path: string) {
}
return res.data as T;
}
export const useQuery = <
D extends any = any,
E extends any = any,
>(
key: string,
config?: SWRConfiguration<D, E>,
): SWRResponse<D, E> & { loading: boolean } => {
const res = useSWR(key, config);
return {
...res,
loading: res.data == null && res.error == null,
};
};