commit
c66d5662b4
@ -1,8 +0,0 @@
|
||||
# Please override by creating a .env.local file at the same directory
|
||||
# Required
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Optional
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
REACT_APP_HAS_UPDATE=false
|
||||
REACT_APP_LOG_REDUX_EVENT=false
|
@ -0,0 +1,24 @@
|
||||
# Override by duplicating me and rename to .env.local
|
||||
# The following environment variables will only be used during development
|
||||
|
||||
# Required
|
||||
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
|
||||
# Optional
|
||||
|
||||
# Allow Unsecured connection to your backend
|
||||
REACT_APP_PROXY_SECURE=true
|
||||
|
||||
# Allow websocket connection in Socket.IO
|
||||
REACT_APP_ALLOW_WEBSOCKET=true
|
||||
|
||||
# Display update section in settings
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
|
||||
# Display update notification in notification center
|
||||
REACT_APP_HAS_UPDATE=false
|
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "react-app"
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
build
|
@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
[ -n "$CI" ] && exit 0
|
||||
|
||||
cd frontend
|
||||
npx pretty-quick --staged
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import Socketio from ".";
|
||||
import { log } from "../../utilities/logger";
|
||||
|
||||
export function useSocketIOReducer(reducer: SocketIO.Reducer) {
|
||||
useEffect(() => {
|
||||
Socketio.addReducer(reducer);
|
||||
log("info", "listening to SocketIO event", reducer.key);
|
||||
return () => {
|
||||
Socketio.removeReducer(reducer);
|
||||
};
|
||||
}, [reducer]);
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
import {
|
||||
episodesMarkBlacklistDirty,
|
||||
episodesMarkDirtyById,
|
||||
episodesRemoveById,
|
||||
episodesResetHistory,
|
||||
movieMarkBlacklistDirty,
|
||||
movieMarkDirtyById,
|
||||
movieMarkWantedDirtyById,
|
||||
movieRemoveById,
|
||||
movieRemoveWantedById,
|
||||
movieResetHistory,
|
||||
movieResetWanted,
|
||||
seriesMarkDirtyById,
|
||||
seriesMarkWantedDirtyById,
|
||||
seriesRemoveById,
|
||||
seriesRemoveWantedById,
|
||||
seriesResetWanted,
|
||||
siteAddNotifications,
|
||||
siteAddProgress,
|
||||
siteBootstrap,
|
||||
siteRemoveProgress,
|
||||
siteUpdateBadges,
|
||||
siteUpdateInitialization,
|
||||
siteUpdateOffline,
|
||||
systemMarkTasksDirty,
|
||||
systemUpdateAllSettings,
|
||||
systemUpdateLanguages,
|
||||
} from "../../@redux/actions";
|
||||
import reduxStore from "../../@redux/store";
|
||||
|
||||
function bindReduxAction<T extends ActionCreator<any>>(action: T) {
|
||||
return (...args: Parameters<T>) => {
|
||||
reduxStore.dispatch(action(...args));
|
||||
};
|
||||
}
|
||||
|
||||
function bindReduxActionWithParam<T extends ActionCreator<any>>(
|
||||
action: T,
|
||||
...param: Parameters<T>
|
||||
) {
|
||||
return () => {
|
||||
reduxStore.dispatch(action(...param));
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultReducer(): SocketIO.Reducer[] {
|
||||
return [
|
||||
{
|
||||
key: "connect",
|
||||
any: bindReduxActionWithParam(siteUpdateOffline, false),
|
||||
},
|
||||
{
|
||||
key: "connect",
|
||||
any: bindReduxAction(siteBootstrap),
|
||||
},
|
||||
{
|
||||
key: "connect_error",
|
||||
any: () => {
|
||||
const initialized = reduxStore.getState().site.initialized;
|
||||
if (initialized === true) {
|
||||
reduxStore.dispatch(siteUpdateOffline(true));
|
||||
} else {
|
||||
reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "disconnect",
|
||||
any: bindReduxActionWithParam(siteUpdateOffline, true),
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
update: (msg) => {
|
||||
if (msg) {
|
||||
const notifications = msg.map<Server.Notification>((message) => ({
|
||||
message,
|
||||
type: "info",
|
||||
id: "backend-message",
|
||||
timeout: 5 * 1000,
|
||||
}));
|
||||
|
||||
reduxStore.dispatch(siteAddNotifications(notifications));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
update: bindReduxAction(siteAddProgress),
|
||||
delete: bindReduxAction(siteRemoveProgress),
|
||||
},
|
||||
{
|
||||
key: "series",
|
||||
update: bindReduxAction(seriesMarkDirtyById),
|
||||
delete: bindReduxAction(seriesRemoveById),
|
||||
},
|
||||
{
|
||||
key: "movie",
|
||||
update: bindReduxAction(movieMarkDirtyById),
|
||||
delete: bindReduxAction(movieRemoveById),
|
||||
},
|
||||
{
|
||||
key: "episode",
|
||||
update: bindReduxAction(episodesMarkDirtyById),
|
||||
delete: bindReduxAction(episodesRemoveById),
|
||||
},
|
||||
{
|
||||
key: "episode-wanted",
|
||||
update: bindReduxAction(seriesMarkWantedDirtyById),
|
||||
delete: bindReduxAction(seriesRemoveWantedById),
|
||||
},
|
||||
{
|
||||
key: "movie-wanted",
|
||||
update: bindReduxAction(movieMarkWantedDirtyById),
|
||||
delete: bindReduxAction(movieRemoveWantedById),
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
any: bindReduxAction(systemUpdateAllSettings),
|
||||
},
|
||||
{
|
||||
key: "languages",
|
||||
any: bindReduxAction(systemUpdateLanguages),
|
||||
},
|
||||
{
|
||||
key: "badges",
|
||||
any: bindReduxAction(siteUpdateBadges),
|
||||
},
|
||||
{
|
||||
key: "movie-history",
|
||||
any: bindReduxAction(movieResetHistory),
|
||||
},
|
||||
{
|
||||
key: "movie-blacklist",
|
||||
any: bindReduxAction(movieMarkBlacklistDirty),
|
||||
},
|
||||
{
|
||||
key: "episode-history",
|
||||
any: bindReduxAction(episodesResetHistory),
|
||||
},
|
||||
{
|
||||
key: "episode-blacklist",
|
||||
any: bindReduxAction(episodesMarkBlacklistDirty),
|
||||
},
|
||||
{
|
||||
key: "reset-episode-wanted",
|
||||
any: bindReduxAction(seriesResetWanted),
|
||||
},
|
||||
{
|
||||
key: "reset-movie-wanted",
|
||||
any: bindReduxAction(movieResetWanted),
|
||||
},
|
||||
{
|
||||
key: "task",
|
||||
any: bindReduxAction(systemMarkTasksDirty),
|
||||
},
|
||||
];
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import BGT from "./";
|
||||
|
||||
export function useIsAnyTaskRunning() {
|
||||
return BGT.isRunning();
|
||||
}
|
||||
|
||||
export function useIsAnyTaskRunningWithId(ids: number[]) {
|
||||
return BGT.hasId(ids);
|
||||
}
|
||||
|
||||
export function useIsGroupTaskRunning(groupName: string) {
|
||||
return BGT.has(groupName);
|
||||
}
|
||||
|
||||
export function useIsGroupTaskRunningWithId(groupName: string, id: number) {
|
||||
return BGT.find(groupName, id);
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import { keys } from "lodash";
|
||||
import {
|
||||
siteAddProgress,
|
||||
siteRemoveProgress,
|
||||
siteUpdateNotifier,
|
||||
siteUpdateProgressCount,
|
||||
} from "../../@redux/actions";
|
||||
import store from "../../@redux/store";
|
||||
|
||||
// A background task manager, use for dispatching task one by one
|
||||
class BackgroundTask {
|
||||
private groups: Task.Group;
|
||||
constructor() {
|
||||
this.groups = {};
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
const message = "Background tasks are still running";
|
||||
if (Object.keys(this.groups).length !== 0) {
|
||||
e.preventDefault();
|
||||
e.returnValue = message;
|
||||
return;
|
||||
}
|
||||
delete e["returnValue"];
|
||||
}
|
||||
|
||||
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
|
||||
if (groupName in this.groups) {
|
||||
this.groups[groupName].push(...tasks);
|
||||
store.dispatch(
|
||||
siteUpdateProgressCount({
|
||||
id: groupName,
|
||||
count: this.groups[groupName].length,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.groups[groupName] = tasks;
|
||||
setTimeout(async () => {
|
||||
for (let index = 0; index < tasks.length; index++) {
|
||||
const task = tasks[index];
|
||||
|
||||
store.dispatch(
|
||||
siteAddProgress([
|
||||
{
|
||||
id: groupName,
|
||||
header: groupName,
|
||||
name: task.name,
|
||||
value: index,
|
||||
count: tasks.length,
|
||||
},
|
||||
])
|
||||
);
|
||||
try {
|
||||
await task.callable(...task.parameters);
|
||||
} catch (error) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
delete this.groups[groupName];
|
||||
store.dispatch(siteRemoveProgress([groupName]));
|
||||
});
|
||||
}
|
||||
|
||||
find(groupName: string, id: number) {
|
||||
if (groupName in this.groups) {
|
||||
return this.groups[groupName].find((v) => v.id === id) !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
has(groupName: string) {
|
||||
return groupName in this.groups;
|
||||
}
|
||||
|
||||
hasId(ids: number[]) {
|
||||
for (const id of ids) {
|
||||
for (const key in this.groups) {
|
||||
const tasks = this.groups[key];
|
||||
if (tasks.find((v) => v.id === id) !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return keys(this.groups).length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const BGT = new BackgroundTask();
|
||||
|
||||
export default BGT;
|
||||
|
||||
export function dispatchTask<T extends Task.Callable>(
|
||||
groupName: string,
|
||||
tasks: Task.Task<T>[],
|
||||
comment?: string
|
||||
) {
|
||||
BGT.dispatch(groupName, tasks);
|
||||
|
||||
if (comment) {
|
||||
store.dispatch(siteUpdateNotifier(comment));
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
declare namespace Task {
|
||||
type Callable = (...args: any[]) => Promise<void>;
|
||||
|
||||
interface Task<FN extends Callable> {
|
||||
name: string;
|
||||
id?: number;
|
||||
callable: FN;
|
||||
parameters: Parameters<FN>;
|
||||
}
|
||||
|
||||
type Group = {
|
||||
[category: string]: Task.Task<Callable>[];
|
||||
};
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
export function createTask<T extends Task.Callable>(
|
||||
name: string,
|
||||
id: number | undefined,
|
||||
callable: T,
|
||||
...parameters: Parameters<T>
|
||||
): Task.Task<T> {
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
callable,
|
||||
parameters,
|
||||
};
|
||||
}
|
@ -0,0 +1,406 @@
|
||||
import {
|
||||
configureStore,
|
||||
createAction,
|
||||
createAsyncThunk,
|
||||
createReducer,
|
||||
} from "@reduxjs/toolkit";
|
||||
import {} from "jest";
|
||||
import { differenceWith, intersectionWith, isString, uniq } from "lodash";
|
||||
import { defaultList, defaultState, TestType } from "../tests/helper";
|
||||
import { createAsyncEntityReducer } from "../utils/factory";
|
||||
|
||||
const newItem: TestType = {
|
||||
id: 123,
|
||||
name: "extended",
|
||||
};
|
||||
|
||||
const longerList: TestType[] = [...defaultList, newItem];
|
||||
const shorterList: TestType[] = defaultList.slice(0, defaultList.length - 1);
|
||||
|
||||
const allResolved = createAsyncThunk("all/resolved", () => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({ total: defaultList.length, data: defaultList });
|
||||
});
|
||||
});
|
||||
|
||||
const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({ total: longerList.length, data: longerList });
|
||||
});
|
||||
});
|
||||
|
||||
const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({ total: shorterList.length, data: shorterList });
|
||||
});
|
||||
});
|
||||
|
||||
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: defaultList.length,
|
||||
data: intersectionWith(defaultList, param, (l, r) => l.id === r),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const idsResolvedLonger = createAsyncThunk(
|
||||
"ids/longer/resolved",
|
||||
(param: number[]) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: longerList.length,
|
||||
data: intersectionWith(longerList, param, (l, r) => l.id === r),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const idsResolvedShorter = createAsyncThunk(
|
||||
"ids/shorter/resolved",
|
||||
(param: number[]) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: shorterList.length,
|
||||
data: intersectionWith(shorterList, param, (l, r) => l.id === r),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const rangeResolved = createAsyncThunk(
|
||||
"range/resolved",
|
||||
(param: Parameter.Range) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: defaultList.length,
|
||||
data: defaultList.slice(param.start, param.start + param.length),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const rangeResolvedLonger = createAsyncThunk(
|
||||
"range/longer/resolved",
|
||||
(param: Parameter.Range) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: longerList.length,
|
||||
data: longerList.slice(param.start, param.start + param.length),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const rangeResolvedShorter = createAsyncThunk(
|
||||
"range/shorter/resolved",
|
||||
(param: Parameter.Range) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
|
||||
resolve({
|
||||
total: shorterList.length,
|
||||
data: shorterList.slice(param.start, param.start + param.length),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const allRejected = createAsyncThunk("all/rejected", () => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
});
|
||||
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
});
|
||||
const rangeRejected = createAsyncThunk(
|
||||
"range/rejected",
|
||||
(param: Parameter.Range) => {
|
||||
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
}
|
||||
);
|
||||
const removeIds = createAction<number[]>("remove/id");
|
||||
const dirty = createAction<number[]>("dirty/id");
|
||||
const reset = createAction("reset");
|
||||
|
||||
const reducer = createReducer(defaultState, (builder) => {
|
||||
createAsyncEntityReducer(builder, (s) => s.entities, {
|
||||
all: allResolved,
|
||||
range: rangeResolved,
|
||||
ids: idsResolved,
|
||||
dirty,
|
||||
removeIds,
|
||||
reset,
|
||||
});
|
||||
createAsyncEntityReducer(builder, (s) => s.entities, {
|
||||
all: allRejected,
|
||||
range: rangeRejected,
|
||||
ids: idsRejected,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.entities, {
|
||||
all: allResolvedLonger,
|
||||
range: rangeResolvedLonger,
|
||||
ids: idsResolvedLonger,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.entities, {
|
||||
all: allResolvedShorter,
|
||||
range: rangeResolvedShorter,
|
||||
ids: idsResolvedShorter,
|
||||
});
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
});
|
||||
expect(store.getState()).toEqual(defaultState);
|
||||
return store;
|
||||
}
|
||||
|
||||
let store = createStore();
|
||||
|
||||
function use(callback: (entities: Async.Entity<TestType>) => void) {
|
||||
const entities = store.getState().entities;
|
||||
callback(entities);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
it("entity update all resolved", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("succeeded");
|
||||
defaultList.forEach((v, index) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids[index]).toEqual(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
expect(entities.didLoaded).toContain(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update all rejected", async () => {
|
||||
await store.dispatch(allRejected());
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.error).not.toBeNull();
|
||||
expect(entities.state).toBe("failed");
|
||||
expect(entities.content.ids).toHaveLength(0);
|
||||
expect(entities.content.entities).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it("entity reset", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(reset());
|
||||
use((entities) => {
|
||||
expect(entities).toEqual(defaultState.entities);
|
||||
});
|
||||
});
|
||||
|
||||
it("entity mark dirty", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
|
||||
store.dispatch(dirty([1, 2, 3]));
|
||||
use((entities) => {
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("dirty");
|
||||
defaultList.forEach((v, index) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids[index]).toEqual(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("delete entity item", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
|
||||
const idsToRemove = [0, 1, 3, 5];
|
||||
const expectResults = differenceWith(
|
||||
defaultList,
|
||||
idsToRemove,
|
||||
(l, r) => l.id === r
|
||||
);
|
||||
|
||||
store.dispatch(removeIds(idsToRemove));
|
||||
use((entities) => {
|
||||
expect(entities.state).toBe("succeeded");
|
||||
idsToRemove.map(String).forEach((v) => {
|
||||
expect(entities.didLoaded).not.toContain(v);
|
||||
});
|
||||
expectResults.forEach((v, index) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids[index]).toEqual(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by range", async () => {
|
||||
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
|
||||
await store.dispatch(rangeResolved({ start: 4, length: 2 }));
|
||||
use((entities) => {
|
||||
expect(entities.content.ids).toHaveLength(defaultList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(4);
|
||||
[0, 1, 4, 5].forEach((v) => {
|
||||
const id = v.toString();
|
||||
expect(entities.content.ids).toContain(id);
|
||||
expect(entities.content.entities[id].id).toEqual(v);
|
||||
expect(entities.didLoaded).toContain(id);
|
||||
});
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by duplicative range", async () => {
|
||||
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
|
||||
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
|
||||
use((entities) => {
|
||||
expect(entities.content.ids).toHaveLength(defaultList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(3);
|
||||
defaultList.slice(0, 3).forEach((v) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids).toContain(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
expect(entities.didLoaded.filter((v) => v === id)).toHaveLength(1);
|
||||
});
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by range and ids", async () => {
|
||||
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
|
||||
await store.dispatch(idsResolved([3]));
|
||||
await store.dispatch(rangeResolved({ start: 2, length: 2 }));
|
||||
use((entries) => {
|
||||
const ids = entries.content.ids.filter(isString);
|
||||
const dedupIds = uniq(ids);
|
||||
expect(ids.length).toBe(dedupIds.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("entity resolved by dirty", async () => {
|
||||
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
|
||||
store.dispatch(dirty([1, 2, 3]));
|
||||
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).not.toContain("1");
|
||||
expect(entities.dirtyEntities).not.toContain("2");
|
||||
expect(entities.dirtyEntities).toContain("3");
|
||||
expect(entities.state).toBe("dirty");
|
||||
});
|
||||
await store.dispatch(rangeResolved({ start: 1, length: 3 }));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).not.toContain("1");
|
||||
expect(entities.dirtyEntities).not.toContain("2");
|
||||
expect(entities.dirtyEntities).not.toContain("3");
|
||||
expect(entities.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by ids", async () => {
|
||||
await store.dispatch(idsResolved([999]));
|
||||
use((entities) => {
|
||||
expect(entities.content.ids).toHaveLength(defaultList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(0);
|
||||
expect(entities.content.entities).not.toHaveProperty("999");
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity resolved dirty by ids", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2, 3, 4]));
|
||||
store.dispatch(dirty([0, 1, 2, 3]));
|
||||
await store.dispatch(idsResolved([0, 1]));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(2);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(5);
|
||||
expect(entities.error).toBeNull();
|
||||
expect(entities.state).toBe("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity resolved non-exist by ids", async () => {
|
||||
await store.dispatch(idsResolved([0, 1]));
|
||||
store.dispatch(dirty([999]));
|
||||
await store.dispatch(idsResolved([999]));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by variant range", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
|
||||
await store.dispatch(rangeResolvedLonger({ start: 0, length: 2 }));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.state).toBe("succeeded");
|
||||
expect(entities.content.ids).toHaveLength(longerList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(2);
|
||||
longerList.slice(0, 2).forEach((v) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids).toContain(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
});
|
||||
});
|
||||
|
||||
await store.dispatch(allResolved());
|
||||
await store.dispatch(rangeResolvedShorter({ start: 0, length: 2 }));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.state).toBe("succeeded");
|
||||
expect(entities.content.ids).toHaveLength(shorterList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(2);
|
||||
shorterList.slice(0, 2).forEach((v) => {
|
||||
const id = v.id.toString();
|
||||
expect(entities.content.ids).toContain(id);
|
||||
expect(entities.content.entities[id]).toEqual(v);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("entity update by variant ids", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
|
||||
await store.dispatch(idsResolvedLonger([2, 3, 4]));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.state).toBe("succeeded");
|
||||
expect(entities.content.ids).toHaveLength(longerList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(3);
|
||||
Array(3)
|
||||
.fill(undefined)
|
||||
.forEach((v) => {
|
||||
expect(entities.content.ids[v]).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
await store.dispatch(allResolved());
|
||||
await store.dispatch(idsResolvedShorter([2, 3, 4]));
|
||||
use((entities) => {
|
||||
expect(entities.dirtyEntities).toHaveLength(0);
|
||||
expect(entities.state).toBe("succeeded");
|
||||
expect(entities.content.ids).toHaveLength(shorterList.length);
|
||||
expect(entities.content.ids.filter(isString)).toHaveLength(3);
|
||||
Array(3)
|
||||
.fill(undefined)
|
||||
.forEach((v) => {
|
||||
expect(entities.content.ids[v]).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,155 @@
|
||||
import {
|
||||
configureStore,
|
||||
createAction,
|
||||
createAsyncThunk,
|
||||
createReducer,
|
||||
} from "@reduxjs/toolkit";
|
||||
import {} from "jest";
|
||||
import { defaultState, TestType } from "../tests/helper";
|
||||
import { createAsyncItemReducer } from "../utils/factory";
|
||||
|
||||
// Item
|
||||
const defaultItem: TestType = { id: 0, name: "test" };
|
||||
const allResolved = createAsyncThunk("all/resolved", () => {
|
||||
return new Promise<TestType>((resolve) => {
|
||||
resolve(defaultItem);
|
||||
});
|
||||
});
|
||||
const allRejected = createAsyncThunk("all/rejected", () => {
|
||||
return new Promise<TestType>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
});
|
||||
const dirty = createAction("dirty/ids");
|
||||
|
||||
const reducer = createReducer(defaultState, (builder) => {
|
||||
createAsyncItemReducer(builder, (s) => s.item, { all: allResolved, dirty });
|
||||
createAsyncItemReducer(builder, (s) => s.item, { all: allRejected });
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
});
|
||||
expect(store.getState()).toEqual(defaultState);
|
||||
return store;
|
||||
}
|
||||
|
||||
let store = createStore();
|
||||
|
||||
function use(callback: (entities: Async.Item<TestType>) => void) {
|
||||
const item = store.getState().item;
|
||||
callback(item);
|
||||
}
|
||||
|
||||
// Begin Test Section
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
it("item loading", async () => {
|
||||
return new Promise<void>((done) => {
|
||||
store.dispatch(allResolved()).finally(() => {
|
||||
use((item) => {
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
done();
|
||||
});
|
||||
use((item) => {
|
||||
expect(item.state).toBe("loading");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("item uninitialized -> succeeded", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("succeeded");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
||||
|
||||
it("item uninitialized -> failed", async () => {
|
||||
await store.dispatch(allRejected());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("failed");
|
||||
expect(item.error).not.toBeNull();
|
||||
expect(item.content).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("item uninitialized -> dirty", () => {
|
||||
store.dispatch(dirty());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("uninitialized");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("item succeeded -> failed", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
await store.dispatch(allRejected());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("failed");
|
||||
expect(item.error).not.toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
||||
|
||||
it("item failed -> succeeded", async () => {
|
||||
await store.dispatch(allRejected());
|
||||
await store.dispatch(allResolved());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("succeeded");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
||||
|
||||
it("item succeeded -> dirty", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(dirty());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("dirty");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
||||
|
||||
it("item failed -> dirty", async () => {
|
||||
await store.dispatch(allRejected());
|
||||
store.dispatch(dirty());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("dirty");
|
||||
expect(item.error).not.toBeNull();
|
||||
expect(item.content).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("item dirty -> failed", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(dirty());
|
||||
await store.dispatch(allRejected());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("failed");
|
||||
expect(item.error).not.toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
||||
|
||||
it("item dirty -> succeeded", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(dirty());
|
||||
await store.dispatch(allResolved());
|
||||
use((item) => {
|
||||
expect(item.state).toBe("succeeded");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toEqual(defaultItem);
|
||||
});
|
||||
});
|
@ -0,0 +1,252 @@
|
||||
import {
|
||||
configureStore,
|
||||
createAction,
|
||||
createAsyncThunk,
|
||||
createReducer,
|
||||
} from "@reduxjs/toolkit";
|
||||
import {} from "jest";
|
||||
import { intersectionWith } from "lodash";
|
||||
import { defaultList, defaultState, TestType } from "../tests/helper";
|
||||
import { createAsyncListReducer } from "../utils/factory";
|
||||
|
||||
const allResolved = createAsyncThunk("all/resolved", () => {
|
||||
return new Promise<TestType[]>((resolve) => {
|
||||
resolve(defaultList);
|
||||
});
|
||||
});
|
||||
const allRejected = createAsyncThunk("all/rejected", () => {
|
||||
return new Promise<TestType[]>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
});
|
||||
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
|
||||
return new Promise<TestType[]>((resolve) => {
|
||||
resolve(intersectionWith(defaultList, param, (l, r) => l.id === r));
|
||||
});
|
||||
});
|
||||
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
|
||||
return new Promise<TestType[]>((resolve, rejected) => {
|
||||
rejected("Error");
|
||||
});
|
||||
});
|
||||
const removeIds = createAction<number[]>("remove/id");
|
||||
const dirty = createAction<number[]>("dirty/id");
|
||||
|
||||
const reducer = createReducer(defaultState, (builder) => {
|
||||
createAsyncListReducer(builder, (s) => s.list, {
|
||||
all: allResolved,
|
||||
ids: idsResolved,
|
||||
removeIds,
|
||||
dirty,
|
||||
});
|
||||
createAsyncListReducer(builder, (s) => s.list, {
|
||||
all: allRejected,
|
||||
ids: idsRejected,
|
||||
});
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
});
|
||||
expect(store.getState()).toEqual(defaultState);
|
||||
return store;
|
||||
}
|
||||
|
||||
let store = createStore();
|
||||
|
||||
function use(callback: (list: Async.List<TestType>) => void) {
|
||||
const list = store.getState().list;
|
||||
callback(list);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
it("list all uninitialized -> succeeded", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
use((list) => {
|
||||
expect(list.content).toEqual(defaultList);
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.didLoaded).toHaveLength(defaultList.length);
|
||||
expect(list.error).toBeNull();
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list all uninitialized -> failed", async () => {
|
||||
await store.dispatch(allRejected());
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(0);
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.error).not.toBeNull();
|
||||
expect(list.state).toEqual("failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("list uninitialized -> dirty", () => {
|
||||
store.dispatch(dirty([0, 1]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(0);
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.error).toBeNull();
|
||||
expect(list.state).toEqual("uninitialized");
|
||||
});
|
||||
});
|
||||
|
||||
it("list succeeded -> dirty", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(dirty([1, 2, 3]));
|
||||
use((list) => {
|
||||
expect(list.content).toEqual(defaultList);
|
||||
expect(list.dirtyEntities).toHaveLength(3);
|
||||
expect(list.error).toBeNull();
|
||||
expect(list.state).toEqual("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids uninitialized -> succeeded", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(3);
|
||||
expect(list.didLoaded).toHaveLength(3);
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.error).toBeNull();
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids succeeded -> dirty", async () => {
|
||||
await store.dispatch(idsResolved([0, 1]));
|
||||
store.dispatch(dirty([2, 3]));
|
||||
use((list) => {
|
||||
expect(list.dirtyEntities).toHaveLength(2);
|
||||
expect(list.state).toEqual("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids succeeded -> dirty", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
store.dispatch(dirty([2, 3]));
|
||||
use((list) => {
|
||||
expect(list.dirtyEntities).toHaveLength(2);
|
||||
expect(list.state).toEqual("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids update data", async () => {
|
||||
await store.dispatch(idsResolved([0, 1]));
|
||||
await store.dispatch(idsResolved([3, 4]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(4);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids update duplicative data", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
await store.dispatch(idsResolved([2, 3]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(4);
|
||||
expect(list.didLoaded).toHaveLength(4);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids update new data", async () => {
|
||||
await store.dispatch(idsResolved([0, 1]));
|
||||
await store.dispatch(idsResolved([2, 3]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(4);
|
||||
expect(list.didLoaded).toHaveLength(4);
|
||||
expect(list.content[1].id).toBe(2);
|
||||
expect(list.content[0].id).toBe(3);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids empty data", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
await store.dispatch(idsResolved([999]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(3);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids duplicative dirty", async () => {
|
||||
await store.dispatch(idsResolved([0]));
|
||||
store.dispatch(dirty([2, 2]));
|
||||
use((list) => {
|
||||
expect(list.dirtyEntities).toHaveLength(1);
|
||||
expect(list.dirtyEntities).toContain("2");
|
||||
expect(list.state).toEqual("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids resolved dirty", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
store.dispatch(dirty([2, 3]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(3);
|
||||
expect(list.dirtyEntities).toContain("2");
|
||||
expect(list.dirtyEntities).toContain("3");
|
||||
expect(list.state).toBe("dirty");
|
||||
});
|
||||
});
|
||||
|
||||
it("list ids resolved dirty", async () => {
|
||||
await store.dispatch(idsResolved([0, 1, 2]));
|
||||
store.dispatch(dirty([1, 2, 3, 999]));
|
||||
await store.dispatch(idsResolved([1, 2]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(3);
|
||||
expect(list.dirtyEntities).not.toContain("1");
|
||||
expect(list.dirtyEntities).not.toContain("2");
|
||||
expect(list.state).toBe("dirty");
|
||||
});
|
||||
|
||||
await store.dispatch(idsResolved([3]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(4);
|
||||
expect(list.dirtyEntities).not.toContain("3");
|
||||
expect(list.state).toBe("dirty");
|
||||
});
|
||||
|
||||
await store.dispatch(idsResolved([999]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(4);
|
||||
expect(list.dirtyEntities).not.toContain("999");
|
||||
expect(list.state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list remove ids", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
const totalSize = store.getState().list.content.length;
|
||||
|
||||
store.dispatch(removeIds([1, 2]));
|
||||
use((list) => {
|
||||
expect(list.content).toHaveLength(totalSize - 2);
|
||||
expect(list.content.map((v) => v.id)).not.toContain(1);
|
||||
expect(list.content.map((v) => v.id)).not.toContain(2);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("list remove dirty ids", async () => {
|
||||
await store.dispatch(allResolved());
|
||||
store.dispatch(dirty([1, 2, 3]));
|
||||
store.dispatch(removeIds([1, 2]));
|
||||
use((list) => {
|
||||
expect(list.dirtyEntities).not.toContain("1");
|
||||
expect(list.dirtyEntities).not.toContain("2");
|
||||
expect(list.state).toEqual("dirty");
|
||||
});
|
||||
store.dispatch(removeIds([3]));
|
||||
use((list) => {
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.state).toEqual("succeeded");
|
||||
});
|
||||
});
|
@ -1,123 +0,0 @@
|
||||
import {
|
||||
ActionCallback,
|
||||
ActionDispatcher,
|
||||
AsyncActionCreator,
|
||||
AsyncActionDispatcher,
|
||||
AvailableCreator,
|
||||
AvailableType,
|
||||
PromiseCreator,
|
||||
} from "../types";
|
||||
|
||||
function asyncActionFactory<T extends PromiseCreator>(
|
||||
type: string,
|
||||
promise: T,
|
||||
args: Parameters<T>
|
||||
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type,
|
||||
payload: {
|
||||
loading: true,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
promise(...args)
|
||||
.then((val) => {
|
||||
dispatch({
|
||||
type,
|
||||
payload: {
|
||||
loading: false,
|
||||
item: val,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch({
|
||||
type,
|
||||
error: true,
|
||||
payload: {
|
||||
loading: false,
|
||||
item: err,
|
||||
parameters: args,
|
||||
},
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createAsyncAction<T extends PromiseCreator>(
|
||||
type: string,
|
||||
promise: T
|
||||
) {
|
||||
return (...args: Parameters<T>) => asyncActionFactory(type, promise, args);
|
||||
}
|
||||
|
||||
// Create a action which combine multiple ActionDispatcher and execute them at once
|
||||
function combineActionFactory(
|
||||
dispatchers: AvailableType<any>[]
|
||||
): ActionDispatcher {
|
||||
return (dispatch) => {
|
||||
dispatchers.forEach((fn) => {
|
||||
if (typeof fn === "function") {
|
||||
fn(dispatch);
|
||||
} else {
|
||||
dispatch(fn);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCombineAction<T extends AvailableCreator>(fn: T) {
|
||||
return (...args: Parameters<T>) => combineActionFactory(fn(...args));
|
||||
}
|
||||
|
||||
function combineAsyncActionFactory(
|
||||
dispatchers: AsyncActionDispatcher<any>[]
|
||||
): AsyncActionDispatcher<any> {
|
||||
return (dispatch) => {
|
||||
const promises = dispatchers.map((v) => v(dispatch));
|
||||
return Promise.all(promises) as Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAsyncCombineAction<T extends AsyncActionCreator>(fn: T) {
|
||||
return (...args: Parameters<T>) => combineAsyncActionFactory(fn(...args));
|
||||
}
|
||||
|
||||
export function callbackActionFactory(
|
||||
dispatchers: AsyncActionDispatcher<any>[],
|
||||
success: ActionCallback,
|
||||
error?: ActionCallback
|
||||
): ActionDispatcher<any> {
|
||||
return (dispatch) => {
|
||||
const promises = dispatchers.map((v) => v(dispatch));
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
const action = success();
|
||||
if (action !== undefined) {
|
||||
dispatch(action);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const action = error && error();
|
||||
if (action !== undefined) {
|
||||
dispatch(action);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCallbackAction<T extends AsyncActionCreator>(
|
||||
fn: T,
|
||||
success: ActionCallback,
|
||||
error?: ActionCallback
|
||||
) {
|
||||
return (...args: Parameters<T>) =>
|
||||
callbackActionFactory(fn(args), success, error);
|
||||
}
|
@ -1,47 +1,84 @@
|
||||
import { createDeleteAction } from "../../@socketio/reducer";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import {
|
||||
MOVIES_DELETE_ITEMS,
|
||||
MOVIES_DELETE_WANTED_ITEMS,
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
MOVIES_UPDATE_LIST,
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
} from "../constants";
|
||||
import { createAsyncAction } from "./factory";
|
||||
|
||||
export const movieUpdateList = createAsyncAction(
|
||||
MOVIES_UPDATE_LIST,
|
||||
(id?: number[]) => MoviesApi.movies(id)
|
||||
export const movieUpdateByRange = createAsyncThunk(
|
||||
"movies/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await MoviesApi.moviesBy(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieDeleteItems = createDeleteAction(MOVIES_DELETE_ITEMS);
|
||||
export const movieUpdateById = createAsyncThunk(
|
||||
"movies/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await MoviesApi.movies(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateAll = createAsyncThunk(
|
||||
"movies/update/all",
|
||||
async () => {
|
||||
const response = await MoviesApi.movies();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieRemoveById = createAction<number[]>("movies/remove");
|
||||
|
||||
export const movieMarkDirtyById = createAction<number[]>(
|
||||
"movies/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const movieUpdateWantedList = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
(radarrid: number[]) => MoviesApi.wantedBy(radarrid)
|
||||
export const movieUpdateWantedById = createAsyncThunk(
|
||||
"movies/wanted/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await MoviesApi.wantedBy(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieDeleteWantedItems = createDeleteAction(
|
||||
MOVIES_DELETE_WANTED_ITEMS
|
||||
export const movieRemoveWantedById = createAction<number[]>(
|
||||
"movies/wanted/remove/id"
|
||||
);
|
||||
|
||||
export const movieUpdateWantedByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
(start: number, length: number) => MoviesApi.wanted(start, length)
|
||||
export const movieResetWanted = createAction("movies/wanted/reset");
|
||||
|
||||
export const movieMarkWantedDirtyById = createAction<number[]>(
|
||||
"movies/wanted/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const movieUpdateWantedByRange = createAsyncThunk(
|
||||
"movies/wanted/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await MoviesApi.wanted(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateHistoryList = createAsyncAction(
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
() => MoviesApi.history()
|
||||
export const movieUpdateHistoryByRange = createAsyncThunk(
|
||||
"movies/history/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await MoviesApi.history(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_LIST,
|
||||
(start: number, length: number) => MoviesApi.moviesBy(start, length)
|
||||
export const movieMarkHistoryDirty = createAction<number[]>(
|
||||
"movies/history/mark_dirty"
|
||||
);
|
||||
|
||||
export const movieResetHistory = createAction("movie/history/reset");
|
||||
|
||||
export const movieUpdateBlacklist = createAsyncThunk(
|
||||
"movies/blacklist/update",
|
||||
async () => {
|
||||
const response = await MoviesApi.blacklist();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateBlacklist = createAsyncAction(
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
() => MoviesApi.blacklist()
|
||||
export const movieMarkBlacklistDirty = createAction(
|
||||
"movies/blacklist/mark_dirty"
|
||||
);
|
||||
|
@ -1,61 +1,106 @@
|
||||
import { createDeleteAction } from "../../@socketio/reducer";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { EpisodesApi, SeriesApi } from "../../apis";
|
||||
import {
|
||||
SERIES_DELETE_EPISODES,
|
||||
SERIES_DELETE_ITEMS,
|
||||
SERIES_DELETE_WANTED_ITEMS,
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
SERIES_UPDATE_LIST,
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
} from "../constants";
|
||||
import { createAsyncAction } from "./factory";
|
||||
|
||||
export const seriesUpdateWantedList = createAsyncAction(
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
(episodeid: number[]) => EpisodesApi.wantedBy(episodeid)
|
||||
export const seriesUpdateWantedById = createAsyncThunk(
|
||||
"series/wanted/update/id",
|
||||
async (episodeid: number[]) => {
|
||||
const response = await EpisodesApi.wantedBy(episodeid);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesDeleteWantedItems = createDeleteAction(
|
||||
SERIES_DELETE_WANTED_ITEMS
|
||||
export const seriesUpdateWantedByRange = createAsyncThunk(
|
||||
"series/wanted/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await EpisodesApi.wanted(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateWantedByRange = createAsyncAction(
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
(start: number, length: number) => EpisodesApi.wanted(start, length)
|
||||
export const seriesRemoveWantedById = createAction<number[]>(
|
||||
"series/wanted/remove/id"
|
||||
);
|
||||
|
||||
export const seriesUpdateList = createAsyncAction(
|
||||
SERIES_UPDATE_LIST,
|
||||
(id?: number[]) => SeriesApi.series(id)
|
||||
export const seriesResetWanted = createAction("series/wanted/reset");
|
||||
|
||||
export const seriesMarkWantedDirtyById = createAction<number[]>(
|
||||
"series/wanted/mark_dirty/episode_id"
|
||||
);
|
||||
|
||||
export const seriesRemoveById = createAction<number[]>("series/remove");
|
||||
|
||||
export const seriesMarkDirtyById = createAction<number[]>(
|
||||
"series/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const seriesUpdateById = createAsyncThunk(
|
||||
"series/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await SeriesApi.series(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateAll = createAsyncThunk(
|
||||
"series/update/all",
|
||||
async () => {
|
||||
const response = await SeriesApi.series();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateByRange = createAsyncThunk(
|
||||
"series/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await SeriesApi.seriesBy(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesDeleteItems = createDeleteAction(SERIES_DELETE_ITEMS);
|
||||
export const episodesRemoveById = createAction<number[]>("episodes/remove");
|
||||
|
||||
export const episodeUpdateBy = createAsyncAction(
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
(seriesid: number[]) => EpisodesApi.bySeriesId(seriesid)
|
||||
export const episodesMarkDirtyById = createAction<number[]>(
|
||||
"episodes/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const episodeDeleteItems = createDeleteAction(SERIES_DELETE_EPISODES);
|
||||
export const episodeUpdateBySeriesId = createAsyncThunk(
|
||||
"episodes/update/series_id",
|
||||
async (seriesid: number[]) => {
|
||||
const response = await EpisodesApi.bySeriesId(seriesid);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const episodeUpdateById = createAsyncAction(
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
(episodeid: number[]) => EpisodesApi.byEpisodeId(episodeid)
|
||||
export const episodeUpdateById = createAsyncThunk(
|
||||
"episodes/update/episodes_id",
|
||||
async (episodeid: number[]) => {
|
||||
const response = await EpisodesApi.byEpisodeId(episodeid);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateByRange = createAsyncAction(
|
||||
SERIES_UPDATE_LIST,
|
||||
(start: number, length: number) => SeriesApi.seriesBy(start, length)
|
||||
export const episodesUpdateHistoryByRange = createAsyncThunk(
|
||||
"episodes/history/update/range",
|
||||
async (param: Parameter.Range) => {
|
||||
const response = await EpisodesApi.history(param);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateHistoryList = createAsyncAction(
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
() => EpisodesApi.history()
|
||||
export const episodesMarkHistoryDirty = createAction<number[]>(
|
||||
"episodes/history/update"
|
||||
);
|
||||
|
||||
export const episodesResetHistory = createAction("episodes/history/reset");
|
||||
|
||||
export const episodesUpdateBlacklist = createAsyncThunk(
|
||||
"episodes/blacklist/update",
|
||||
async () => {
|
||||
const response = await EpisodesApi.blacklist();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateBlacklist = createAsyncAction(
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
() => EpisodesApi.blacklist()
|
||||
export const episodesMarkBlacklistDirty = createAction(
|
||||
"episodes/blacklist/update"
|
||||
);
|
||||
|
@ -1,63 +1,60 @@
|
||||
import { createAction } from "redux-actions";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { BadgesApi } from "../../apis";
|
||||
import {
|
||||
SITE_BADGE_UPDATE,
|
||||
SITE_INITIALIZED,
|
||||
SITE_INITIALIZE_FAILED,
|
||||
SITE_NEED_AUTH,
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
SITE_OFFLINE_UPDATE,
|
||||
SITE_PROGRESS_ADD,
|
||||
SITE_PROGRESS_REMOVE,
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
} from "../constants";
|
||||
import { createAsyncAction, createCallbackAction } from "./factory";
|
||||
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
|
||||
|
||||
export const bootstrap = createCallbackAction(
|
||||
() => [systemUpdateLanguagesAll(), systemUpdateSettings(), badgeUpdateAll()],
|
||||
() => siteInitialized(),
|
||||
() => siteInitializationFailed()
|
||||
);
|
||||
import { waitFor } from "../../utilities";
|
||||
import { systemUpdateAllSettings } from "./system";
|
||||
|
||||
// TODO: Override error messages
|
||||
export const siteInitializationFailed = createAction(SITE_INITIALIZE_FAILED);
|
||||
export const siteBootstrap = createAsyncThunk(
|
||||
"site/bootstrap",
|
||||
(_: undefined, { dispatch }) => {
|
||||
return Promise.all([
|
||||
dispatch(systemUpdateAllSettings()),
|
||||
dispatch(siteUpdateBadges()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
const siteInitialized = createAction(SITE_INITIALIZED);
|
||||
export const siteUpdateInitialization = createAction<string | true>(
|
||||
"site/initialization/update"
|
||||
);
|
||||
|
||||
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH);
|
||||
export const siteRedirectToAuth = createAction("site/redirect_auth");
|
||||
|
||||
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () =>
|
||||
BadgesApi.all()
|
||||
export const siteAddNotifications = createAction<Server.Notification[]>(
|
||||
"site/notifications/add"
|
||||
);
|
||||
|
||||
export const siteAddNotifications = createAction(
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
(notification: ReduxStore.Notification[]) => notification
|
||||
export const siteRemoveNotifications = createAction<string>(
|
||||
"site/notifications/remove"
|
||||
);
|
||||
|
||||
export const siteRemoveNotifications = createAction(
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
(id: string) => id
|
||||
);
|
||||
export const siteAddProgress =
|
||||
createAction<Site.Progress[]>("site/progress/add");
|
||||
|
||||
export const siteAddProgress = createAction(
|
||||
SITE_PROGRESS_ADD,
|
||||
(progress: ReduxStore.Progress[]) => progress
|
||||
);
|
||||
export const siteUpdateProgressCount = createAction<{
|
||||
id: string;
|
||||
count: number;
|
||||
}>("site/progress/update_count");
|
||||
|
||||
export const siteRemoveProgress = createAction(
|
||||
SITE_PROGRESS_REMOVE,
|
||||
(id: string) => id
|
||||
export const siteRemoveProgress = createAsyncThunk(
|
||||
"site/progress/remove",
|
||||
async (ids: string[]) => {
|
||||
await waitFor(3 * 1000);
|
||||
return ids;
|
||||
}
|
||||
);
|
||||
|
||||
export const siteChangeSidebar = createAction(
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
(id: string) => id
|
||||
export const siteUpdateNotifier = createAction<string>(
|
||||
"site/progress/update_notifier"
|
||||
);
|
||||
|
||||
export const siteUpdateOffline = createAction(
|
||||
SITE_OFFLINE_UPDATE,
|
||||
(state: boolean) => state
|
||||
export const siteChangeSidebar = createAction<string>("site/sidebar/update");
|
||||
|
||||
export const siteUpdateOffline = createAction<boolean>("site/offline/update");
|
||||
|
||||
export const siteUpdateBadges = createAsyncThunk(
|
||||
"site/badges/update",
|
||||
async () => {
|
||||
const response = await BadgesApi.all();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
@ -1,64 +1,87 @@
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { ProvidersApi, SystemApi } from "../../apis";
|
||||
import {
|
||||
SYSTEM_UPDATE_HEALTH,
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
SYSTEM_UPDATE_LOGS,
|
||||
SYSTEM_UPDATE_PROVIDERS,
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
SYSTEM_UPDATE_STATUS,
|
||||
SYSTEM_UPDATE_TASKS,
|
||||
} from "../constants";
|
||||
import { createAsyncAction, createAsyncCombineAction } from "./factory";
|
||||
|
||||
export const systemUpdateLanguagesAll = createAsyncCombineAction(() => [
|
||||
systemUpdateLanguages(),
|
||||
systemUpdateLanguagesProfiles(),
|
||||
]);
|
||||
|
||||
export const systemUpdateLanguages = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
() => SystemApi.languages()
|
||||
export const systemUpdateAllSettings = createAsyncThunk(
|
||||
"system/update",
|
||||
async (_: undefined, { dispatch }) => {
|
||||
await Promise.all([
|
||||
dispatch(systemUpdateSettings()),
|
||||
dispatch(systemUpdateLanguages()),
|
||||
dispatch(systemUpdateLanguagesProfiles()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateLanguagesProfiles = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
() => SystemApi.languagesProfileList()
|
||||
export const systemUpdateLanguages = createAsyncThunk(
|
||||
"system/languages/update",
|
||||
async () => {
|
||||
const response = await SystemApi.languages();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
|
||||
SystemApi.status()
|
||||
export const systemUpdateLanguagesProfiles = createAsyncThunk(
|
||||
"system/languages/profile/update",
|
||||
async () => {
|
||||
const response = await SystemApi.languagesProfileList();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () =>
|
||||
SystemApi.health()
|
||||
export const systemUpdateStatus = createAsyncThunk(
|
||||
"system/status/update",
|
||||
async () => {
|
||||
const response = await SystemApi.status();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
|
||||
SystemApi.getTasks()
|
||||
export const systemUpdateHealth = createAsyncThunk(
|
||||
"system/health/update",
|
||||
async () => {
|
||||
const response = await SystemApi.health();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
|
||||
SystemApi.logs()
|
||||
export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
|
||||
|
||||
export const systemUpdateTasks = createAsyncThunk(
|
||||
"system/tasks/update",
|
||||
async () => {
|
||||
const response = await SystemApi.tasks();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateReleases = createAsyncAction(
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
() => SystemApi.releases()
|
||||
export const systemUpdateLogs = createAsyncThunk(
|
||||
"system/logs/update",
|
||||
async () => {
|
||||
const response = await SystemApi.logs();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateSettings = createAsyncAction(
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
() => SystemApi.settings()
|
||||
export const systemUpdateReleases = createAsyncThunk(
|
||||
"system/releases/update",
|
||||
async () => {
|
||||
const response = await SystemApi.releases();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const providerUpdateList = createAsyncAction(
|
||||
SYSTEM_UPDATE_PROVIDERS,
|
||||
() => ProvidersApi.providers()
|
||||
export const systemUpdateSettings = createAsyncThunk(
|
||||
"system/settings/update",
|
||||
async () => {
|
||||
const response = await SystemApi.settings();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
|
||||
systemUpdateSettings(),
|
||||
systemUpdateLanguagesAll(),
|
||||
]);
|
||||
export const providerUpdateList = createAsyncThunk(
|
||||
"providers/update",
|
||||
async () => {
|
||||
const response = await ProvidersApi.providers();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
@ -1,43 +0,0 @@
|
||||
// Provider action
|
||||
|
||||
// System action
|
||||
export const SYSTEM_UPDATE_LANGUAGES_LIST = "UPDATE_ALL_LANGUAGES_LIST";
|
||||
export const SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST =
|
||||
"UPDATE_LANGUAGES_PROFILE_LIST";
|
||||
export const SYSTEM_UPDATE_STATUS = "UPDATE_SYSTEM_STATUS";
|
||||
export const SYSTEM_UPDATE_HEALTH = "UPDATE_SYSTEM_HEALTH";
|
||||
export const SYSTEM_UPDATE_TASKS = "UPDATE_SYSTEM_TASKS";
|
||||
export const SYSTEM_UPDATE_LOGS = "UPDATE_SYSTEM_LOGS";
|
||||
export const SYSTEM_UPDATE_RELEASES = "SYSTEM_UPDATE_RELEASES";
|
||||
export const SYSTEM_UPDATE_SETTINGS = "UPDATE_SYSTEM_SETTINGS";
|
||||
export const SYSTEM_UPDATE_PROVIDERS = "SYSTEM_UPDATE_PROVIDERS";
|
||||
|
||||
// Series action
|
||||
export const SERIES_UPDATE_WANTED_LIST = "UPDATE_SERIES_WANTED_LIST";
|
||||
export const SERIES_DELETE_WANTED_ITEMS = "SERIES_DELETE_WANTED_ITEMS";
|
||||
export const SERIES_UPDATE_EPISODE_LIST = "UPDATE_SERIES_EPISODE_LIST";
|
||||
export const SERIES_DELETE_EPISODES = "SERIES_DELETE_EPISODES";
|
||||
export const SERIES_UPDATE_HISTORY_LIST = "UPDATE_SERIES_HISTORY_LIST";
|
||||
export const SERIES_UPDATE_LIST = "UPDATE_SEIRES_LIST";
|
||||
export const SERIES_DELETE_ITEMS = "SERIES_DELETE_ITEMS";
|
||||
export const SERIES_UPDATE_BLACKLIST = "UPDATE_SERIES_BLACKLIST";
|
||||
|
||||
// Movie action
|
||||
export const MOVIES_UPDATE_LIST = "UPDATE_MOVIE_LIST";
|
||||
export const MOVIES_DELETE_ITEMS = "MOVIES_DELETE_ITEMS";
|
||||
export const MOVIES_UPDATE_WANTED_LIST = "UPDATE_MOVIE_WANTED_LIST";
|
||||
export const MOVIES_DELETE_WANTED_ITEMS = "MOVIES_DELETE_WANTED_ITEMS";
|
||||
export const MOVIES_UPDATE_HISTORY_LIST = "UPDATE_MOVIE_HISTORY_LIST";
|
||||
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
|
||||
|
||||
// Site Action
|
||||
export const SITE_NEED_AUTH = "SITE_NEED_AUTH";
|
||||
export const SITE_INITIALIZED = "SITE_SYSTEM_INITIALIZED";
|
||||
export const SITE_INITIALIZE_FAILED = "SITE_INITIALIZE_FAILED";
|
||||
export const SITE_NOTIFICATIONS_ADD = "SITE_NOTIFICATIONS_ADD";
|
||||
export const SITE_NOTIFICATIONS_REMOVE = "SITE_NOTIFICATIONS_REMOVE";
|
||||
export const SITE_PROGRESS_ADD = "SITE_PROGRESS_ADD";
|
||||
export const SITE_PROGRESS_REMOVE = "SITE_PROGRESS_REMOVE";
|
||||
export const SITE_SIDEBAR_UPDATE = "SITE_SIDEBAR_UPDATE";
|
||||
export const SITE_BADGE_UPDATE = "SITE_BADGE_UPDATE";
|
||||
export const SITE_OFFLINE_UPDATE = "SITE_OFFLINE_UPDATE";
|
@ -0,0 +1,29 @@
|
||||
import { AsyncThunk } from "@reduxjs/toolkit";
|
||||
import { useEffect } from "react";
|
||||
import { log } from "../../utilities/logger";
|
||||
import { useReduxAction } from "./base";
|
||||
|
||||
export function useAutoUpdate(item: Async.Item<any>, update: () => void) {
|
||||
useEffect(() => {
|
||||
if (item.state === "uninitialized" || item.state === "dirty") {
|
||||
update();
|
||||
}
|
||||
}, [item.state, update]);
|
||||
}
|
||||
|
||||
export function useAutoDirtyUpdate(
|
||||
item: Async.List<any> | Async.Entity<any>,
|
||||
updateAction: AsyncThunk<any, number[], {}>
|
||||
) {
|
||||
const { state, dirtyEntities } = item;
|
||||
const hasDirty = dirtyEntities.length > 0 && state === "dirty";
|
||||
|
||||
const update = useReduxAction(updateAction);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDirty) {
|
||||
log("info", "updating dirty entities...");
|
||||
update(dirtyEntities.map(Number));
|
||||
}
|
||||
}, [hasDirty, dirtyEntities, update]);
|
||||
}
|
@ -1,36 +1,24 @@
|
||||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { createCallbackAction } from "../actions/factory";
|
||||
import { ActionCallback, AsyncActionDispatcher } from "../types";
|
||||
import { AppDispatch, RootState } from "../store";
|
||||
|
||||
// function use
|
||||
export function useReduxStore<T extends (store: ReduxStore) => any>(
|
||||
export function useReduxStore<T extends (store: RootState) => any>(
|
||||
selector: T
|
||||
) {
|
||||
return useSelector<ReduxStore, ReturnType<T>>(selector);
|
||||
return useSelector<RootState, ReturnType<T>>(selector);
|
||||
}
|
||||
|
||||
export function useReduxAction<T extends (...args: any[]) => void>(action: T) {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
|
||||
action,
|
||||
dispatch,
|
||||
]);
|
||||
export function useAppDispatch() {
|
||||
return useDispatch<AppDispatch>();
|
||||
}
|
||||
|
||||
export function useReduxActionWith<
|
||||
T extends (...args: any[]) => AsyncActionDispatcher<any>
|
||||
>(action: T, success: ActionCallback) {
|
||||
const dispatch = useDispatch();
|
||||
// TODO: Fix type
|
||||
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
|
||||
const dispatch = useAppDispatch();
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const callbackAction = createCallbackAction(
|
||||
() => [action(...args)],
|
||||
success
|
||||
);
|
||||
|
||||
dispatch(callbackAction());
|
||||
},
|
||||
[dispatch, action, success]
|
||||
(...args: Parameters<T>) => dispatch(action(...args)),
|
||||
[action, dispatch]
|
||||
);
|
||||
}
|
||||
|
@ -1,393 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks";
|
||||
import { buildOrderList } from "../../utilites";
|
||||
import {
|
||||
episodeDeleteItems,
|
||||
episodeUpdateBy,
|
||||
episodeUpdateById,
|
||||
movieUpdateBlacklist,
|
||||
movieUpdateHistoryList,
|
||||
movieUpdateList,
|
||||
movieUpdateWantedList,
|
||||
providerUpdateList,
|
||||
seriesUpdateBlacklist,
|
||||
seriesUpdateHistoryList,
|
||||
seriesUpdateList,
|
||||
seriesUpdateWantedList,
|
||||
systemUpdateHealth,
|
||||
systemUpdateLanguages,
|
||||
systemUpdateLanguagesProfiles,
|
||||
systemUpdateLogs,
|
||||
systemUpdateReleases,
|
||||
systemUpdateSettingsAll,
|
||||
systemUpdateStatus,
|
||||
systemUpdateTasks,
|
||||
} from "../actions";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
function stateBuilder<T, D extends (...args: any[]) => any>(
|
||||
t: T,
|
||||
d: D
|
||||
): [Readonly<T>, D] {
|
||||
return [t, d];
|
||||
}
|
||||
|
||||
export function useSystemSettings() {
|
||||
const update = useReduxAction(systemUpdateSettingsAll);
|
||||
const items = useReduxStore((s) => s.system.settings);
|
||||
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemLogs() {
|
||||
const items = useReduxStore(({ system }) => system.logs);
|
||||
const update = useReduxAction(systemUpdateLogs);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemTasks() {
|
||||
const items = useReduxStore((s) => s.system.tasks);
|
||||
const update = useReduxAction(systemUpdateTasks);
|
||||
const reducer = useMemo<SocketIO.Reducer>(() => ({ key: "task", update }), [
|
||||
update,
|
||||
]);
|
||||
useSocketIOReducer(reducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemStatus() {
|
||||
const items = useReduxStore((s) => s.system.status.data);
|
||||
const update = useReduxAction(systemUpdateStatus);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemHealth() {
|
||||
const update = useReduxAction(systemUpdateHealth);
|
||||
const items = useReduxStore((s) => s.system.health);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemProviders() {
|
||||
const update = useReduxAction(providerUpdateList);
|
||||
const items = useReduxStore((d) => d.system.providers);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSystemReleases() {
|
||||
const items = useReduxStore(({ system }) => system.releases);
|
||||
const update = useReduxAction(systemUpdateReleases);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useLanguageProfiles() {
|
||||
const action = useReduxAction(systemUpdateLanguagesProfiles);
|
||||
const items = useReduxStore((s) => s.system.languagesProfiles.data);
|
||||
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
export function useProfileBy(id: number | null | undefined) {
|
||||
const [profiles] = useLanguageProfiles();
|
||||
return useMemo(() => profiles.find((v) => v.profileId === id), [
|
||||
id,
|
||||
profiles,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useLanguages(enabled: boolean = false) {
|
||||
const action = useReduxAction(systemUpdateLanguages);
|
||||
const items = useReduxStore((s) =>
|
||||
enabled ? s.system.enabledLanguage.data : s.system.languages.data
|
||||
);
|
||||
return stateBuilder(items, action);
|
||||
}
|
||||
|
||||
function useLanguageGetter(enabled: boolean = false) {
|
||||
const [languages] = useLanguages(enabled);
|
||||
return useCallback(
|
||||
(code?: string) => {
|
||||
if (code === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return languages.find((v) => v.code2 === code);
|
||||
}
|
||||
},
|
||||
[languages]
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguageBy(code?: string) {
|
||||
const getter = useLanguageGetter();
|
||||
return useMemo(() => getter(code), [code, getter]);
|
||||
}
|
||||
|
||||
// Convert languageprofile items to language
|
||||
export function useProfileItems(profile?: Profile.Languages) {
|
||||
const getter = useLanguageGetter(true);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
profile?.items.map<Language>(({ language, hi, forced }) => {
|
||||
const name = getter(language)?.name ?? "";
|
||||
return {
|
||||
hi: hi === "True",
|
||||
forced: forced === "True",
|
||||
code2: language,
|
||||
name,
|
||||
};
|
||||
}) ?? [],
|
||||
[getter, profile?.items]
|
||||
);
|
||||
}
|
||||
|
||||
export function useRawSeries() {
|
||||
const update = useReduxAction(seriesUpdateList);
|
||||
const items = useReduxStore((d) => d.series.seriesList);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSeries(order = true) {
|
||||
const [rawSeries, action] = useRawSeries();
|
||||
const series = useMemo<AsyncState<Item.Series[]>>(() => {
|
||||
const state = rawSeries.data;
|
||||
if (order) {
|
||||
return {
|
||||
...rawSeries,
|
||||
data: buildOrderList(state),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...rawSeries,
|
||||
data: Object.values(state.items),
|
||||
};
|
||||
}
|
||||
}, [rawSeries, order]);
|
||||
return stateBuilder(series, action);
|
||||
}
|
||||
|
||||
export function useSerieBy(id?: number) {
|
||||
const [series, updateSerie] = useRawSeries();
|
||||
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
|
||||
const items = series.data.items;
|
||||
let item: Item.Series | null = null;
|
||||
if (id && !isNaN(id) && id in items) {
|
||||
item = items[id];
|
||||
}
|
||||
return {
|
||||
...series,
|
||||
data: item,
|
||||
};
|
||||
}, [id, series]);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (id && !isNaN(id)) {
|
||||
updateSerie([id]);
|
||||
}
|
||||
}, [id, updateSerie]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serie.data === null) {
|
||||
update();
|
||||
}
|
||||
}, [serie.data, update]);
|
||||
return stateBuilder(serie, update);
|
||||
}
|
||||
|
||||
export function useEpisodesBy(seriesId?: number) {
|
||||
const action = useReduxAction(episodeUpdateBy);
|
||||
const update = useCallback(() => {
|
||||
if (seriesId !== undefined && !isNaN(seriesId)) {
|
||||
action([seriesId]);
|
||||
}
|
||||
}, [action, seriesId]);
|
||||
|
||||
const list = useReduxStore((d) => d.series.episodeList);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (seriesId !== undefined && !isNaN(seriesId)) {
|
||||
return list.data.filter((v) => v.sonarrSeriesId === seriesId);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [seriesId, list.data]);
|
||||
|
||||
const state: AsyncState<Item.Episode[]> = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
data: items,
|
||||
}),
|
||||
[list, items]
|
||||
);
|
||||
|
||||
const actionById = useReduxAction(episodeUpdateById);
|
||||
const wrapActionById = useWrapToOptionalId(actionById);
|
||||
const deleteAction = useReduxAction(episodeDeleteItems);
|
||||
const episodeReducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "episode", update: wrapActionById, delete: deleteAction }),
|
||||
[wrapActionById, deleteAction]
|
||||
);
|
||||
useSocketIOReducer(episodeReducer);
|
||||
|
||||
const wrapAction = useWrapToOptionalId(action);
|
||||
const seriesReducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "series", update: wrapAction }),
|
||||
[wrapAction]
|
||||
);
|
||||
useSocketIOReducer(seriesReducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(state, update);
|
||||
}
|
||||
|
||||
export function useRawMovies() {
|
||||
const update = useReduxAction(movieUpdateList);
|
||||
const items = useReduxStore((d) => d.movie.movieList);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useMovies(order = true) {
|
||||
const [rawMovies, action] = useRawMovies();
|
||||
const movies = useMemo<AsyncState<Item.Movie[]>>(() => {
|
||||
const state = rawMovies.data;
|
||||
if (order) {
|
||||
return {
|
||||
...rawMovies,
|
||||
data: buildOrderList(state),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...rawMovies,
|
||||
data: Object.values(state.items),
|
||||
};
|
||||
}
|
||||
}, [rawMovies, order]);
|
||||
return stateBuilder(movies, action);
|
||||
}
|
||||
|
||||
export function useMovieBy(id?: number) {
|
||||
const [movies, updateMovies] = useRawMovies();
|
||||
const movie = useMemo<AsyncState<Item.Movie | null>>(() => {
|
||||
const items = movies.data.items;
|
||||
let item: Item.Movie | null = null;
|
||||
if (id && !isNaN(id) && id in items) {
|
||||
item = items[id];
|
||||
}
|
||||
return {
|
||||
...movies,
|
||||
data: item,
|
||||
};
|
||||
}, [id, movies]);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (id && !isNaN(id)) {
|
||||
updateMovies([id]);
|
||||
}
|
||||
}, [id, updateMovies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movie.data === null) {
|
||||
update();
|
||||
}
|
||||
}, [movie.data, update]);
|
||||
return stateBuilder(movie, update);
|
||||
}
|
||||
|
||||
export function useWantedSeries() {
|
||||
const update = useReduxAction(seriesUpdateWantedList);
|
||||
const items = useReduxStore((d) => d.series.wantedEpisodesList);
|
||||
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useWantedMovies() {
|
||||
const update = useReduxAction(movieUpdateWantedList);
|
||||
const items = useReduxStore((d) => d.movie.wantedMovieList);
|
||||
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useBlacklistMovies() {
|
||||
const update = useReduxAction(movieUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.movie.blacklist);
|
||||
const reducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "movie-blacklist", any: update }),
|
||||
[update]
|
||||
);
|
||||
useSocketIOReducer(reducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useBlacklistSeries() {
|
||||
const update = useReduxAction(seriesUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.series.blacklist);
|
||||
const reducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "episode-blacklist", any: update }),
|
||||
[update]
|
||||
);
|
||||
useSocketIOReducer(reducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useMoviesHistory() {
|
||||
const update = useReduxAction(movieUpdateHistoryList);
|
||||
const items = useReduxStore((s) => s.movie.historyList);
|
||||
const reducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "movie-history", update }),
|
||||
[update]
|
||||
);
|
||||
useSocketIOReducer(reducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
|
||||
export function useSeriesHistory() {
|
||||
const update = useReduxAction(seriesUpdateHistoryList);
|
||||
const items = useReduxStore((s) => s.series.historyList);
|
||||
const reducer = useMemo<SocketIO.Reducer>(
|
||||
() => ({ key: "episode-history", update }),
|
||||
[update]
|
||||
);
|
||||
useSocketIOReducer(reducer);
|
||||
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
return stateBuilder(items, update);
|
||||
}
|
||||
export * from "./movies";
|
||||
export * from "./series";
|
||||
export * from "./site";
|
||||
export * from "./system";
|
||||
|
@ -0,0 +1,67 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useEntityItemById, useEntityToList } from "../../utilities";
|
||||
import {
|
||||
movieUpdateBlacklist,
|
||||
movieUpdateById,
|
||||
movieUpdateWantedById,
|
||||
} from "../actions";
|
||||
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
export function useMovieEntities() {
|
||||
const entities = useReduxStore((d) => d.movies.movieList);
|
||||
|
||||
useAutoDirtyUpdate(entities, movieUpdateById);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
export function useMovies() {
|
||||
const rawMovies = useMovieEntities();
|
||||
const content = useEntityToList(rawMovies.content);
|
||||
const movies = useMemo<Async.List<Item.Movie>>(() => {
|
||||
return {
|
||||
...rawMovies,
|
||||
keyName: rawMovies.content.keyName,
|
||||
content,
|
||||
};
|
||||
}, [rawMovies, content]);
|
||||
return movies;
|
||||
}
|
||||
|
||||
export function useMovieBy(id: number) {
|
||||
const movies = useMovieEntities();
|
||||
const action = useReduxAction(movieUpdateById);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!isNaN(id)) {
|
||||
action([id]);
|
||||
}
|
||||
}, [id, action]);
|
||||
|
||||
const movie = useEntityItemById(movies, id.toString());
|
||||
|
||||
useAutoUpdate(movie, update);
|
||||
return movie;
|
||||
}
|
||||
|
||||
export function useWantedMovies() {
|
||||
const items = useReduxStore((d) => d.movies.wantedMovieList);
|
||||
|
||||
useAutoDirtyUpdate(items, movieUpdateWantedById);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useBlacklistMovies() {
|
||||
const update = useReduxAction(movieUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.movies.blacklist);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useMoviesHistory() {
|
||||
const items = useReduxStore((s) => s.movies.historyList);
|
||||
|
||||
return items;
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useEntityItemById, useEntityToList } from "../../utilities";
|
||||
import {
|
||||
episodesUpdateBlacklist,
|
||||
episodeUpdateById,
|
||||
episodeUpdateBySeriesId,
|
||||
seriesUpdateById,
|
||||
seriesUpdateWantedById,
|
||||
} from "../actions";
|
||||
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
export function useSerieEntities() {
|
||||
const items = useReduxStore((d) => d.series.seriesList);
|
||||
|
||||
useAutoDirtyUpdate(items, seriesUpdateById);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSeries() {
|
||||
const rawSeries = useSerieEntities();
|
||||
const content = useEntityToList(rawSeries.content);
|
||||
const series = useMemo<Async.List<Item.Series>>(() => {
|
||||
return {
|
||||
...rawSeries,
|
||||
keyName: rawSeries.content.keyName,
|
||||
content,
|
||||
};
|
||||
}, [rawSeries, content]);
|
||||
return series;
|
||||
}
|
||||
|
||||
export function useSerieBy(id: number) {
|
||||
const series = useSerieEntities();
|
||||
const action = useReduxAction(seriesUpdateById);
|
||||
const serie = useEntityItemById(series, String(id));
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!isNaN(id)) {
|
||||
action([id]);
|
||||
}
|
||||
}, [id, action]);
|
||||
|
||||
useAutoUpdate(serie, update);
|
||||
return serie;
|
||||
}
|
||||
|
||||
export function useEpisodesBy(seriesId: number) {
|
||||
const action = useReduxAction(episodeUpdateBySeriesId);
|
||||
const update = useCallback(() => {
|
||||
if (!isNaN(seriesId)) {
|
||||
action([seriesId]);
|
||||
}
|
||||
}, [action, seriesId]);
|
||||
|
||||
const episodes = useReduxStore((d) => d.series.episodeList);
|
||||
|
||||
const newContent = useMemo(() => {
|
||||
return episodes.content.filter((v) => v.sonarrSeriesId === seriesId);
|
||||
}, [seriesId, episodes.content]);
|
||||
|
||||
const newList: Async.List<Item.Episode> = useMemo(
|
||||
() => ({
|
||||
...episodes,
|
||||
content: newContent,
|
||||
}),
|
||||
[episodes, newContent]
|
||||
);
|
||||
|
||||
// FIXME
|
||||
useEffect(() => {
|
||||
update();
|
||||
}, [update]);
|
||||
|
||||
useAutoDirtyUpdate(episodes, episodeUpdateById);
|
||||
|
||||
return newList;
|
||||
}
|
||||
|
||||
export function useWantedSeries() {
|
||||
const items = useReduxStore((d) => d.series.wantedEpisodesList);
|
||||
|
||||
useAutoDirtyUpdate(items, seriesUpdateWantedById);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useBlacklistSeries() {
|
||||
const update = useReduxAction(episodesUpdateBlacklist);
|
||||
const items = useReduxStore((d) => d.series.blacklist);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSeriesHistory() {
|
||||
const items = useReduxStore((s) => s.series.historyList);
|
||||
|
||||
return items;
|
||||
}
|
@ -0,0 +1,131 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
providerUpdateList,
|
||||
systemUpdateHealth,
|
||||
systemUpdateLogs,
|
||||
systemUpdateReleases,
|
||||
systemUpdateStatus,
|
||||
systemUpdateTasks,
|
||||
} from "../actions";
|
||||
import { useAutoUpdate } from "./async";
|
||||
import { useReduxAction, useReduxStore } from "./base";
|
||||
|
||||
export function useSystemSettings() {
|
||||
const items = useReduxStore((s) => s.system.settings);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSystemLogs() {
|
||||
const items = useReduxStore(({ system }) => system.logs);
|
||||
const update = useReduxAction(systemUpdateLogs);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSystemTasks() {
|
||||
const items = useReduxStore((s) => s.system.tasks);
|
||||
const update = useReduxAction(systemUpdateTasks);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSystemStatus() {
|
||||
const items = useReduxStore((s) => s.system.status);
|
||||
const update = useReduxAction(systemUpdateStatus);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items.content;
|
||||
}
|
||||
|
||||
export function useSystemHealth() {
|
||||
const items = useReduxStore((s) => s.system.health);
|
||||
const update = useReduxAction(systemUpdateHealth);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSystemProviders() {
|
||||
const update = useReduxAction(providerUpdateList);
|
||||
const items = useReduxStore((d) => d.system.providers);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSystemReleases() {
|
||||
const items = useReduxStore(({ system }) => system.releases);
|
||||
const update = useReduxAction(systemUpdateReleases);
|
||||
|
||||
useAutoUpdate(items, update);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useLanguageProfiles() {
|
||||
const items = useReduxStore((s) => s.system.languagesProfiles);
|
||||
|
||||
return items.content;
|
||||
}
|
||||
|
||||
export function useProfileBy(id: number | null | undefined) {
|
||||
const profiles = useLanguageProfiles();
|
||||
return useMemo(
|
||||
() => profiles?.find((v) => v.profileId === id),
|
||||
[id, profiles]
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguages() {
|
||||
const data = useReduxStore((s) => s.system.languages);
|
||||
|
||||
const languages = useMemo<Language.Info[]>(
|
||||
() => data.content?.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
|
||||
[data.content]
|
||||
);
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
export function useEnabledLanguages() {
|
||||
const data = useReduxStore((s) => s.system.languages);
|
||||
|
||||
const enabled = useMemo<Language.Info[]>(
|
||||
() =>
|
||||
data.content
|
||||
?.filter((v) => v.enabled)
|
||||
.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
|
||||
[data.content]
|
||||
);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function useLanguageBy(code?: string) {
|
||||
const languages = useLanguages();
|
||||
return useMemo(
|
||||
() => languages.find((v) => v.code2 === code),
|
||||
[languages, code]
|
||||
);
|
||||
}
|
||||
|
||||
// Convert languageprofile items to language
|
||||
export function useProfileItemsToLanguages(profile?: Language.Profile) {
|
||||
const languages = useLanguages();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
|
||||
const name = languages.find((v) => v.code2 === code)?.name ?? "";
|
||||
return {
|
||||
hi: hi === "True",
|
||||
forced: forced === "True",
|
||||
code2: code,
|
||||
name,
|
||||
};
|
||||
}) ?? [],
|
||||
[languages, profile?.items]
|
||||
);
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import { combineReducers } from "redux";
|
||||
import movie from "./movie";
|
||||
import movies from "./movie";
|
||||
import series from "./series";
|
||||
import site from "./site";
|
||||
import system from "./system";
|
||||
|
||||
export default combineReducers({
|
||||
system,
|
||||
const AllReducers = {
|
||||
movies,
|
||||
series,
|
||||
movie,
|
||||
site,
|
||||
});
|
||||
system,
|
||||
};
|
||||
|
||||
export default AllReducers;
|
||||
|
@ -1,81 +1,68 @@
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
import {
|
||||
MOVIES_DELETE_ITEMS,
|
||||
MOVIES_DELETE_WANTED_ITEMS,
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
MOVIES_UPDATE_LIST,
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
import { defaultAOS } from "../utils";
|
||||
movieMarkBlacklistDirty,
|
||||
movieMarkDirtyById,
|
||||
movieMarkHistoryDirty,
|
||||
movieMarkWantedDirtyById,
|
||||
movieRemoveById,
|
||||
movieRemoveWantedById,
|
||||
movieResetHistory,
|
||||
movieResetWanted,
|
||||
movieUpdateAll,
|
||||
movieUpdateBlacklist,
|
||||
movieUpdateById,
|
||||
movieUpdateByRange,
|
||||
movieUpdateHistoryByRange,
|
||||
movieUpdateWantedById,
|
||||
movieUpdateWantedByRange,
|
||||
} from "../actions";
|
||||
import { AsyncUtility } from "../utils";
|
||||
import {
|
||||
deleteOrderListItemBy,
|
||||
updateAsyncState,
|
||||
updateOrderIdState,
|
||||
} from "../utils/mapper";
|
||||
createAsyncEntityReducer,
|
||||
createAsyncItemReducer,
|
||||
} from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Movie, any>(
|
||||
{
|
||||
[MOVIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedMovieList,
|
||||
"radarrId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[MOVIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: deleteOrderListItemBy(action, state.wantedMovieList),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Movie[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
|
||||
};
|
||||
},
|
||||
[MOVIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
movieList: deleteOrderListItemBy(action, state.movieList),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_BLACKLIST]: (
|
||||
state,
|
||||
action: AsyncAction<Blacklist.Movie[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
blacklist: updateAsyncState(action, state.blacklist.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
movieList: defaultAOS(),
|
||||
wantedMovieList: defaultAOS(),
|
||||
historyList: { updating: true, data: [] },
|
||||
blacklist: { updating: true, data: [] },
|
||||
}
|
||||
);
|
||||
interface Movie {
|
||||
movieList: Async.Entity<Item.Movie>;
|
||||
wantedMovieList: Async.Entity<Wanted.Movie>;
|
||||
historyList: Async.Entity<History.Movie>;
|
||||
blacklist: Async.Item<Blacklist.Movie[]>;
|
||||
}
|
||||
|
||||
const defaultMovie: Movie = {
|
||||
movieList: AsyncUtility.getDefaultEntity("radarrId"),
|
||||
wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
|
||||
historyList: AsyncUtility.getDefaultEntity("id"),
|
||||
blacklist: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultMovie, (builder) => {
|
||||
createAsyncEntityReducer(builder, (s) => s.movieList, {
|
||||
range: movieUpdateByRange,
|
||||
ids: movieUpdateById,
|
||||
removeIds: movieRemoveById,
|
||||
all: movieUpdateAll,
|
||||
dirty: movieMarkDirtyById,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
|
||||
range: movieUpdateWantedByRange,
|
||||
ids: movieUpdateWantedById,
|
||||
removeIds: movieRemoveWantedById,
|
||||
dirty: movieMarkWantedDirtyById,
|
||||
reset: movieResetWanted,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.historyList, {
|
||||
range: movieUpdateHistoryByRange,
|
||||
dirty: movieMarkHistoryDirty,
|
||||
reset: movieResetHistory,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.blacklist, {
|
||||
all: movieUpdateBlacklist,
|
||||
dirty: movieMarkBlacklistDirty,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
@ -1,116 +1,100 @@
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
import {
|
||||
SERIES_DELETE_EPISODES,
|
||||
SERIES_DELETE_ITEMS,
|
||||
SERIES_DELETE_WANTED_ITEMS,
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
SERIES_UPDATE_LIST,
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
import { defaultAOS } from "../utils";
|
||||
episodesMarkBlacklistDirty,
|
||||
episodesMarkDirtyById,
|
||||
episodesMarkHistoryDirty,
|
||||
episodesRemoveById,
|
||||
episodesResetHistory,
|
||||
episodesUpdateBlacklist,
|
||||
episodesUpdateHistoryByRange,
|
||||
episodeUpdateById,
|
||||
episodeUpdateBySeriesId,
|
||||
seriesMarkDirtyById,
|
||||
seriesMarkWantedDirtyById,
|
||||
seriesRemoveById,
|
||||
seriesRemoveWantedById,
|
||||
seriesResetWanted,
|
||||
seriesUpdateAll,
|
||||
seriesUpdateById,
|
||||
seriesUpdateByRange,
|
||||
seriesUpdateWantedById,
|
||||
seriesUpdateWantedByRange,
|
||||
} from "../actions";
|
||||
import { AsyncUtility, ReducerUtility } from "../utils";
|
||||
import {
|
||||
deleteAsyncListItemBy,
|
||||
deleteOrderListItemBy,
|
||||
updateAsyncList,
|
||||
updateAsyncState,
|
||||
updateOrderIdState,
|
||||
} from "../utils/mapper";
|
||||
createAsyncEntityReducer,
|
||||
createAsyncItemReducer,
|
||||
createAsyncListReducer,
|
||||
} from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Series, any>(
|
||||
{
|
||||
[SERIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedEpisodesList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: deleteOrderListItemBy(
|
||||
action,
|
||||
state.wantedEpisodesList
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_EPISODE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<Item.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
episodeList: updateAsyncList(
|
||||
action,
|
||||
state.episodeList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_DELETE_EPISODES]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
episodeList: deleteAsyncListItemBy(
|
||||
action,
|
||||
state.episodeList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Series>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
seriesList: updateOrderIdState(
|
||||
action,
|
||||
state.seriesList,
|
||||
"sonarrSeriesId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
seriesList: deleteOrderListItemBy(action, state.seriesList),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_BLACKLIST]: (
|
||||
state,
|
||||
action: AsyncAction<Blacklist.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
blacklist: updateAsyncState(action, state.blacklist.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
seriesList: defaultAOS(),
|
||||
wantedEpisodesList: defaultAOS(),
|
||||
episodeList: { updating: true, data: [] },
|
||||
historyList: { updating: true, data: [] },
|
||||
blacklist: { updating: true, data: [] },
|
||||
}
|
||||
);
|
||||
interface Series {
|
||||
seriesList: Async.Entity<Item.Series>;
|
||||
wantedEpisodesList: Async.Entity<Wanted.Episode>;
|
||||
episodeList: Async.List<Item.Episode>;
|
||||
historyList: Async.Entity<History.Episode>;
|
||||
blacklist: Async.Item<Blacklist.Episode[]>;
|
||||
}
|
||||
|
||||
const defaultSeries: Series = {
|
||||
seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
|
||||
wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
|
||||
episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
|
||||
historyList: AsyncUtility.getDefaultEntity("id"),
|
||||
blacklist: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSeries, (builder) => {
|
||||
createAsyncEntityReducer(builder, (s) => s.seriesList, {
|
||||
range: seriesUpdateByRange,
|
||||
ids: seriesUpdateById,
|
||||
removeIds: seriesRemoveById,
|
||||
all: seriesUpdateAll,
|
||||
});
|
||||
|
||||
builder.addCase(seriesMarkDirtyById, (state, action) => {
|
||||
const series = state.seriesList;
|
||||
const dirtyIds = action.payload.map(String);
|
||||
|
||||
ReducerUtility.markDirty(series, dirtyIds);
|
||||
|
||||
// Update episode list
|
||||
const episodes = state.episodeList;
|
||||
const dirtyIdsSet = new Set(dirtyIds);
|
||||
const dirtyEpisodeIds = episodes.content
|
||||
.filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
|
||||
.map((v) => String(v.sonarrEpisodeId));
|
||||
|
||||
ReducerUtility.markDirty(episodes, dirtyEpisodeIds);
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
|
||||
range: seriesUpdateWantedByRange,
|
||||
ids: seriesUpdateWantedById,
|
||||
removeIds: seriesRemoveWantedById,
|
||||
dirty: seriesMarkWantedDirtyById,
|
||||
reset: seriesResetWanted,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.historyList, {
|
||||
range: episodesUpdateHistoryByRange,
|
||||
dirty: episodesMarkHistoryDirty,
|
||||
reset: episodesResetHistory,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.blacklist, {
|
||||
all: episodesUpdateBlacklist,
|
||||
dirty: episodesMarkBlacklistDirty,
|
||||
});
|
||||
|
||||
createAsyncListReducer(builder, (s) => s.episodeList, {
|
||||
ids: episodeUpdateBySeriesId,
|
||||
});
|
||||
|
||||
createAsyncListReducer(builder, (s) => s.episodeList, {
|
||||
ids: episodeUpdateById,
|
||||
removeIds: episodesRemoveById,
|
||||
dirty: episodesMarkDirtyById,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
@ -1,100 +1,130 @@
|
||||
import { remove, uniqBy } from "lodash";
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
|
||||
import apis from "../../apis";
|
||||
import { isProdEnv } from "../../utilities";
|
||||
import {
|
||||
SITE_BADGE_UPDATE,
|
||||
SITE_INITIALIZED,
|
||||
SITE_INITIALIZE_FAILED,
|
||||
SITE_NEED_AUTH,
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
SITE_OFFLINE_UPDATE,
|
||||
SITE_PROGRESS_ADD,
|
||||
SITE_PROGRESS_REMOVE,
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
} from "../constants";
|
||||
import { AsyncAction } from "../types";
|
||||
siteAddNotifications,
|
||||
siteAddProgress,
|
||||
siteBootstrap,
|
||||
siteChangeSidebar,
|
||||
siteRedirectToAuth,
|
||||
siteRemoveNotifications,
|
||||
siteRemoveProgress,
|
||||
siteUpdateBadges,
|
||||
siteUpdateInitialization,
|
||||
siteUpdateNotifier,
|
||||
siteUpdateOffline,
|
||||
siteUpdateProgressCount,
|
||||
} from "../actions/site";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Site, any>(
|
||||
{
|
||||
[SITE_NEED_AUTH]: (state) => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
apis.danger_resetApi("NEED_AUTH");
|
||||
interface Site {
|
||||
// Initialization state or error message
|
||||
initialized: boolean | string;
|
||||
offline: boolean;
|
||||
auth: boolean;
|
||||
progress: Site.Progress[];
|
||||
notifier: {
|
||||
content: string | null;
|
||||
timestamp: string;
|
||||
};
|
||||
notifications: Server.Notification[];
|
||||
sidebar: string;
|
||||
badges: Badge;
|
||||
}
|
||||
|
||||
const defaultSite: Site = {
|
||||
initialized: false,
|
||||
auth: true,
|
||||
progress: [],
|
||||
notifier: {
|
||||
content: null,
|
||||
timestamp: String(Date.now()),
|
||||
},
|
||||
notifications: [],
|
||||
sidebar: "",
|
||||
badges: {
|
||||
movies: 0,
|
||||
episodes: 0,
|
||||
providers: 0,
|
||||
status: 0,
|
||||
},
|
||||
offline: false,
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSite, (builder) => {
|
||||
builder
|
||||
.addCase(siteBootstrap.fulfilled, (state) => {
|
||||
state.initialized = true;
|
||||
})
|
||||
.addCase(siteBootstrap.rejected, (state) => {
|
||||
state.initialized = "An Error Occurred When Initializing Bazarr UI";
|
||||
})
|
||||
.addCase(siteRedirectToAuth, (state) => {
|
||||
if (!isProdEnv) {
|
||||
apis._resetApi("NEED_AUTH");
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
auth: false,
|
||||
};
|
||||
},
|
||||
[SITE_INITIALIZED]: (state) => ({
|
||||
...state,
|
||||
initialized: true,
|
||||
}),
|
||||
[SITE_INITIALIZE_FAILED]: (state) => ({
|
||||
...state,
|
||||
initialized: "An Error Occurred When Initializing Bazarr UI",
|
||||
}),
|
||||
[SITE_NOTIFICATIONS_ADD]: (
|
||||
state,
|
||||
action: Action<ReduxStore.Notification[]>
|
||||
) => {
|
||||
const notifications = uniqBy(
|
||||
[...action.payload.reverse(), ...state.notifications],
|
||||
(n) => n.id
|
||||
state.auth = false;
|
||||
})
|
||||
.addCase(siteUpdateInitialization, (state, action) => {
|
||||
state.initialized = action.payload;
|
||||
});
|
||||
|
||||
builder
|
||||
.addCase(siteAddNotifications, (state, action) => {
|
||||
state.notifications = uniqBy(
|
||||
[...action.payload, ...state.notifications],
|
||||
(v) => v.id
|
||||
);
|
||||
return { ...state, notifications };
|
||||
},
|
||||
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => {
|
||||
const notifications = [...state.notifications];
|
||||
remove(notifications, (n) => n.id === action.payload);
|
||||
return { ...state, notifications };
|
||||
},
|
||||
[SITE_PROGRESS_ADD]: (state, action: Action<ReduxStore.Progress[]>) => {
|
||||
const progress = uniqBy(
|
||||
[...action.payload.reverse(), ...state.progress],
|
||||
state.notifications = sortBy(state.notifications, (v) => v.id);
|
||||
})
|
||||
.addCase(siteRemoveNotifications, (state, action) => {
|
||||
remove(state.notifications, (n) => n.id === action.payload);
|
||||
});
|
||||
|
||||
builder
|
||||
.addCase(siteAddProgress, (state, action) => {
|
||||
state.progress = uniqBy(
|
||||
[...action.payload, ...state.progress],
|
||||
(n) => n.id
|
||||
);
|
||||
return { ...state, progress };
|
||||
},
|
||||
[SITE_PROGRESS_REMOVE]: (state, action: Action<string>) => {
|
||||
const progress = [...state.progress];
|
||||
remove(progress, (n) => n.id === action.payload);
|
||||
return { ...state, progress };
|
||||
},
|
||||
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => {
|
||||
return {
|
||||
...state,
|
||||
sidebar: action.payload,
|
||||
};
|
||||
},
|
||||
[SITE_BADGE_UPDATE]: {
|
||||
next: (state, action: AsyncAction<Badge>) => {
|
||||
const badges = action.payload.item;
|
||||
if (badges && action.error !== true) {
|
||||
return { ...state, badges: badges as Badge };
|
||||
}
|
||||
return state;
|
||||
},
|
||||
throw: (state) => state,
|
||||
},
|
||||
[SITE_OFFLINE_UPDATE]: (state, action: Action<boolean>) => {
|
||||
return { ...state, offline: action.payload };
|
||||
},
|
||||
},
|
||||
{
|
||||
initialized: false,
|
||||
auth: true,
|
||||
progress: [],
|
||||
notifications: [],
|
||||
sidebar: "",
|
||||
badges: {
|
||||
movies: 0,
|
||||
episodes: 0,
|
||||
providers: 0,
|
||||
status: 0,
|
||||
},
|
||||
offline: false,
|
||||
}
|
||||
);
|
||||
state.progress = sortBy(state.progress, (v) => v.id);
|
||||
})
|
||||
.addCase(siteRemoveProgress.pending, (state, action) => {
|
||||
// Mark completed
|
||||
intersectionWith(
|
||||
state.progress,
|
||||
action.meta.arg,
|
||||
(l, r) => l.id === r
|
||||
).forEach((v) => {
|
||||
v.value = v.count + 1;
|
||||
});
|
||||
})
|
||||
.addCase(siteRemoveProgress.fulfilled, (state, action) => {
|
||||
pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
|
||||
})
|
||||
.addCase(siteUpdateProgressCount, (state, action) => {
|
||||
const { id, count } = action.payload;
|
||||
const progress = state.progress.find((v) => v.id === id);
|
||||
if (progress) {
|
||||
progress.count = count;
|
||||
}
|
||||
});
|
||||
|
||||
builder.addCase(siteUpdateNotifier, (state, action) => {
|
||||
state.notifier.content = action.payload;
|
||||
state.notifier.timestamp = String(Date.now());
|
||||
});
|
||||
|
||||
builder
|
||||
.addCase(siteChangeSidebar, (state, action) => {
|
||||
state.sidebar = action.payload;
|
||||
})
|
||||
.addCase(siteUpdateOffline, (state, action) => {
|
||||
state.offline = action.payload;
|
||||
})
|
||||
.addCase(siteUpdateBadges.fulfilled, (state, action) => {
|
||||
state.badges = action.payload;
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
@ -1,121 +1,74 @@
|
||||
import { handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
import {
|
||||
SYSTEM_UPDATE_HEALTH,
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
SYSTEM_UPDATE_LOGS,
|
||||
SYSTEM_UPDATE_PROVIDERS,
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
SYSTEM_UPDATE_STATUS,
|
||||
SYSTEM_UPDATE_TASKS,
|
||||
} from "../constants";
|
||||
import { updateAsyncState } from "../utils/mapper";
|
||||
providerUpdateList,
|
||||
systemMarkTasksDirty,
|
||||
systemUpdateHealth,
|
||||
systemUpdateLanguages,
|
||||
systemUpdateLanguagesProfiles,
|
||||
systemUpdateLogs,
|
||||
systemUpdateReleases,
|
||||
systemUpdateSettings,
|
||||
systemUpdateStatus,
|
||||
systemUpdateTasks,
|
||||
} from "../actions";
|
||||
import { AsyncUtility } from "../utils";
|
||||
import { createAsyncItemReducer } from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.System, any>(
|
||||
{
|
||||
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => {
|
||||
const languages = updateAsyncState<Array<ApiLanguage>>(action, []);
|
||||
const enabledLanguage: AsyncState<ApiLanguage[]> = {
|
||||
...languages,
|
||||
data: languages.data.filter((v) => v.enabled),
|
||||
};
|
||||
const newState = {
|
||||
...state,
|
||||
languages,
|
||||
enabledLanguage,
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => {
|
||||
const newState = {
|
||||
...state,
|
||||
languagesProfiles: updateAsyncState<Array<Profile.Languages>>(
|
||||
action,
|
||||
[]
|
||||
),
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_STATUS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
status: updateAsyncState<System.Status | undefined>(
|
||||
action,
|
||||
state.status.data
|
||||
),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_HEALTH]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
health: updateAsyncState(action, state.health.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_TASKS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
providers: updateAsyncState(action, state.providers.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_LOGS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
logs: updateAsyncState(action, state.logs.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_RELEASES]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
releases: updateAsyncState(action, state.releases.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_SETTINGS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
settings: updateAsyncState(action, state.settings.data),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: { updating: true, data: [] },
|
||||
enabledLanguage: { updating: true, data: [] },
|
||||
languagesProfiles: { updating: true, data: [] },
|
||||
status: {
|
||||
updating: true,
|
||||
data: undefined,
|
||||
},
|
||||
health: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
tasks: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
providers: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
logs: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
releases: {
|
||||
updating: true,
|
||||
data: [],
|
||||
},
|
||||
settings: {
|
||||
updating: true,
|
||||
data: undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
interface System {
|
||||
languages: Async.Item<Language.Server[]>;
|
||||
languagesProfiles: Async.Item<Language.Profile[]>;
|
||||
status: Async.Item<System.Status>;
|
||||
health: Async.Item<System.Health[]>;
|
||||
tasks: Async.Item<System.Task[]>;
|
||||
providers: Async.Item<System.Provider[]>;
|
||||
logs: Async.Item<System.Log[]>;
|
||||
releases: Async.Item<ReleaseInfo[]>;
|
||||
settings: Async.Item<Settings>;
|
||||
}
|
||||
|
||||
const defaultSystem: System = {
|
||||
languages: AsyncUtility.getDefaultItem(),
|
||||
languagesProfiles: AsyncUtility.getDefaultItem(),
|
||||
status: AsyncUtility.getDefaultItem(),
|
||||
health: AsyncUtility.getDefaultItem(),
|
||||
tasks: AsyncUtility.getDefaultItem(),
|
||||
providers: AsyncUtility.getDefaultItem(),
|
||||
logs: AsyncUtility.getDefaultItem(),
|
||||
releases: AsyncUtility.getDefaultItem(),
|
||||
settings: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSystem, (builder) => {
|
||||
createAsyncItemReducer(builder, (s) => s.languages, {
|
||||
all: systemUpdateLanguages,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
|
||||
all: systemUpdateLanguagesProfiles,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
|
||||
createAsyncItemReducer(builder, (s) => s.settings, {
|
||||
all: systemUpdateSettings,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.releases, {
|
||||
all: systemUpdateReleases,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.logs, {
|
||||
all: systemUpdateLogs,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.health, {
|
||||
all: systemUpdateHealth,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.tasks, {
|
||||
all: systemUpdateTasks,
|
||||
dirty: systemMarkTasksDirty,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.providers, {
|
||||
all: providerUpdateList,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
@ -1,62 +0,0 @@
|
||||
interface ReduxStore {
|
||||
system: ReduxStore.System;
|
||||
series: ReduxStore.Series;
|
||||
movie: ReduxStore.Movie;
|
||||
site: ReduxStore.Site;
|
||||
}
|
||||
|
||||
namespace ReduxStore {
|
||||
interface Notification {
|
||||
type: "error" | "warning" | "info";
|
||||
id: string;
|
||||
message: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
id: string;
|
||||
header: string;
|
||||
name: string;
|
||||
value: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Site {
|
||||
// Initialization state or error message
|
||||
initialized: boolean | string;
|
||||
auth: boolean;
|
||||
progress: Progress[];
|
||||
notifications: Notification[];
|
||||
sidebar: string;
|
||||
badges: Badge;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
interface System {
|
||||
languages: AsyncState<Array<Language>>;
|
||||
enabledLanguage: AsyncState<Array<Language>>;
|
||||
languagesProfiles: AsyncState<Array<Profile.Languages>>;
|
||||
status: AsyncState<System.Status | undefined>;
|
||||
health: AsyncState<Array<System.Health>>;
|
||||
tasks: AsyncState<Array<System.Task>>;
|
||||
providers: AsyncState<Array<System.Provider>>;
|
||||
logs: AsyncState<Array<System.Log>>;
|
||||
releases: AsyncState<Array<ReleaseInfo>>;
|
||||
settings: AsyncState<Settings | undefined>;
|
||||
}
|
||||
|
||||
interface Series {
|
||||
seriesList: AsyncOrderState<Item.Series>;
|
||||
wantedEpisodesList: AsyncOrderState<Wanted.Episode>;
|
||||
episodeList: AsyncState<Item.Episode[]>;
|
||||
historyList: AsyncState<Array<History.Episode>>;
|
||||
blacklist: AsyncState<Array<Blacklist.Episode>>;
|
||||
}
|
||||
|
||||
interface Movie {
|
||||
movieList: AsyncOrderState<Item.Movie>;
|
||||
wantedMovieList: AsyncOrderState<Wanted.Movie>;
|
||||
historyList: AsyncState<Array<History.Movie>>;
|
||||
blacklist: AsyncState<Array<Blacklist.Movie>>;
|
||||
}
|
||||
}
|
@ -1,17 +1,15 @@
|
||||
import { applyMiddleware, createStore } from "redux";
|
||||
import logger from "redux-logger";
|
||||
import promise from "redux-promise";
|
||||
import trunk from "redux-thunk";
|
||||
import rootReducer from "../reducers";
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import apis from "../../apis";
|
||||
import reducer from "../reducers";
|
||||
|
||||
const plugins = [promise, trunk];
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
});
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
|
||||
) {
|
||||
plugins.push(logger);
|
||||
}
|
||||
// FIXME
|
||||
apis.dispatch = store.dispatch;
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
const store = createStore(rootReducer, applyMiddleware(...plugins));
|
||||
export default store;
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { AsyncUtility } from "../utils";
|
||||
|
||||
export interface TestType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Reducer {
|
||||
item: Async.Item<TestType>;
|
||||
list: Async.List<TestType>;
|
||||
entities: Async.Entity<TestType>;
|
||||
}
|
||||
|
||||
export const defaultState: Reducer = {
|
||||
item: AsyncUtility.getDefaultItem(),
|
||||
list: AsyncUtility.getDefaultList("id"),
|
||||
entities: AsyncUtility.getDefaultEntity("id"),
|
||||
};
|
||||
|
||||
export const defaultItem: TestType = { id: 0, name: "test" };
|
||||
|
||||
export const defaultList: TestType[] = [
|
||||
{ id: 0, name: "test" },
|
||||
{ id: 1, name: "test_1" },
|
||||
{ id: 2, name: "test_2" },
|
||||
{ id: 3, name: "test_3" },
|
||||
{ id: 4, name: "test_4" },
|
||||
{ id: 5, name: "test_5" },
|
||||
{ id: 6, name: "test_6" },
|
||||
{ id: 7, name: "test_6" },
|
||||
];
|
@ -1,22 +0,0 @@
|
||||
import { Dispatch } from "redux";
|
||||
import { Action } from "redux-actions";
|
||||
|
||||
interface AsyncPayload<Payload> {
|
||||
loading: boolean;
|
||||
item?: Payload | Error;
|
||||
parameters: any[];
|
||||
}
|
||||
|
||||
type AvailableType<T> = Action<T> | ActionDispatcher<T>;
|
||||
|
||||
type AsyncAction<Payload> = Action<AsyncPayload<Payload>>;
|
||||
type ActionDispatcher<T = any> = (dispatch: Dispatch<Action<T>>) => void;
|
||||
type AsyncActionDispatcher<T> = (
|
||||
dispatch: Dispatch<AsyncAction<T>>
|
||||
) => Promise<void>;
|
||||
|
||||
type PromiseCreator = (...args: any[]) => Promise<any>;
|
||||
type AvailableCreator = (...args: any[]) => AvailableType<any>[];
|
||||
type AsyncActionCreator = (...args: any[]) => AsyncActionDispatcher<any>[];
|
||||
|
||||
type ActionCallback = () => Action<any> | void;
|
@ -0,0 +1,32 @@
|
||||
import {} from "jest";
|
||||
import { AsyncUtility } from "..";
|
||||
|
||||
interface AsyncTest {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
it("Item Init", () => {
|
||||
const item = AsyncUtility.getDefaultItem<AsyncTest>();
|
||||
expect(item.state).toEqual("uninitialized");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.content).toBeNull();
|
||||
});
|
||||
|
||||
it("List Init", () => {
|
||||
const list = AsyncUtility.getDefaultList<AsyncTest>("id");
|
||||
expect(list.state).toEqual("uninitialized");
|
||||
expect(list.dirtyEntities).toHaveLength(0);
|
||||
expect(list.error).toBeNull();
|
||||
expect(list.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Entity Init", () => {
|
||||
const entity = AsyncUtility.getDefaultEntity<AsyncTest>("id");
|
||||
expect(entity.state).toEqual("uninitialized");
|
||||
expect(entity.dirtyEntities).toHaveLength(0);
|
||||
expect(entity.error).toBeNull();
|
||||
expect(entity.content.ids).toHaveLength(0);
|
||||
expect(entity.content.keyName).toBe("id");
|
||||
expect(entity.content.entities).toMatchObject({});
|
||||
});
|
@ -0,0 +1,339 @@
|
||||
import {
|
||||
ActionCreatorWithoutPayload,
|
||||
ActionCreatorWithPayload,
|
||||
ActionReducerMapBuilder,
|
||||
AsyncThunk,
|
||||
Draft,
|
||||
} from "@reduxjs/toolkit";
|
||||
import {
|
||||
difference,
|
||||
findIndex,
|
||||
isNull,
|
||||
isString,
|
||||
omit,
|
||||
pullAll,
|
||||
pullAllWith,
|
||||
} from "lodash";
|
||||
import { ReducerUtility } from ".";
|
||||
import { conditionalLog } from "../../utilities/logger";
|
||||
|
||||
interface ActionParam<T, ID = null> {
|
||||
range?: AsyncThunk<T, Parameter.Range, {}>;
|
||||
all?: AsyncThunk<T, void, {}>;
|
||||
ids?: AsyncThunk<T, ID[], {}>;
|
||||
removeIds?: ActionCreatorWithPayload<ID[]>;
|
||||
reset?: ActionCreatorWithoutPayload;
|
||||
dirty?: ID extends null
|
||||
? ActionCreatorWithoutPayload
|
||||
: ActionCreatorWithPayload<ID[]>;
|
||||
}
|
||||
|
||||
export function createAsyncItemReducer<S, T>(
|
||||
builder: ActionReducerMapBuilder<S>,
|
||||
getItem: (state: Draft<S>) => Draft<Async.Item<T>>,
|
||||
actions: Pick<ActionParam<T>, "all" | "dirty">
|
||||
) {
|
||||
const { all, dirty } = actions;
|
||||
|
||||
all &&
|
||||
builder
|
||||
.addCase(all.pending, (state) => {
|
||||
const item = getItem(state);
|
||||
item.state = "loading";
|
||||
item.error = null;
|
||||
})
|
||||
.addCase(all.fulfilled, (state, action) => {
|
||||
const item = getItem(state);
|
||||
item.state = "succeeded";
|
||||
item.content = action.payload as Draft<T>;
|
||||
})
|
||||
.addCase(all.rejected, (state, action) => {
|
||||
const item = getItem(state);
|
||||
item.state = "failed";
|
||||
item.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
dirty &&
|
||||
builder.addCase(dirty, (state) => {
|
||||
const item = getItem(state);
|
||||
if (item.state !== "uninitialized") {
|
||||
item.state = "dirty";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createAsyncListReducer<S, T, ID extends Async.IdType>(
|
||||
builder: ActionReducerMapBuilder<S>,
|
||||
getList: (state: Draft<S>) => Draft<Async.List<T>>,
|
||||
actions: ActionParam<T[], ID>
|
||||
) {
|
||||
const { ids, removeIds, all, dirty } = actions;
|
||||
ids &&
|
||||
builder
|
||||
.addCase(ids.pending, (state) => {
|
||||
const list = getList(state);
|
||||
list.state = "loading";
|
||||
list.error = null;
|
||||
})
|
||||
.addCase(ids.fulfilled, (state, action) => {
|
||||
const list = getList(state);
|
||||
|
||||
const {
|
||||
meta: { arg },
|
||||
} = action;
|
||||
|
||||
const strIds = arg.map(String);
|
||||
|
||||
const keyName = list.keyName as keyof T;
|
||||
|
||||
action.payload.forEach((v) => {
|
||||
const idx = findIndex(list.content, [keyName, v[keyName]]);
|
||||
if (idx !== -1) {
|
||||
list.content.splice(idx, 1, v as Draft<T>);
|
||||
} else {
|
||||
list.content.unshift(v as Draft<T>);
|
||||
}
|
||||
});
|
||||
|
||||
ReducerUtility.updateDirty(list, strIds);
|
||||
ReducerUtility.updateDidLoaded(list, strIds);
|
||||
})
|
||||
.addCase(ids.rejected, (state, action) => {
|
||||
const list = getList(state);
|
||||
list.state = "failed";
|
||||
list.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
removeIds &&
|
||||
builder.addCase(removeIds, (state, action) => {
|
||||
const list = getList(state);
|
||||
const keyName = list.keyName as keyof T;
|
||||
|
||||
const removeIds = action.payload.map(String);
|
||||
|
||||
pullAllWith(list.content, removeIds, (lhs, rhs) => {
|
||||
return String((lhs as T)[keyName]) === rhs;
|
||||
});
|
||||
|
||||
ReducerUtility.removeDirty(list, removeIds);
|
||||
ReducerUtility.removeDidLoaded(list, removeIds);
|
||||
});
|
||||
|
||||
all &&
|
||||
builder
|
||||
.addCase(all.pending, (state) => {
|
||||
const list = getList(state);
|
||||
list.state = "loading";
|
||||
list.error = null;
|
||||
})
|
||||
.addCase(all.fulfilled, (state, action) => {
|
||||
const list = getList(state);
|
||||
list.state = "succeeded";
|
||||
list.content = action.payload as Draft<T[]>;
|
||||
list.dirtyEntities = [];
|
||||
|
||||
const ids = action.payload.map((v) =>
|
||||
String(v[list.keyName as keyof T])
|
||||
);
|
||||
ReducerUtility.updateDidLoaded(list, ids);
|
||||
})
|
||||
.addCase(all.rejected, (state, action) => {
|
||||
const list = getList(state);
|
||||
list.state = "failed";
|
||||
list.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
dirty &&
|
||||
builder.addCase(dirty, (state, action) => {
|
||||
const list = getList(state);
|
||||
ReducerUtility.markDirty(list, action.payload.map(String));
|
||||
});
|
||||
}
|
||||
|
||||
export function createAsyncEntityReducer<S, T, ID extends Async.IdType>(
|
||||
builder: ActionReducerMapBuilder<S>,
|
||||
getEntity: (state: Draft<S>) => Draft<Async.Entity<T>>,
|
||||
actions: ActionParam<AsyncDataWrapper<T>, ID>
|
||||
) {
|
||||
const { all, removeIds, ids, range, dirty, reset } = actions;
|
||||
|
||||
const checkSizeUpdate = (entity: Draft<Async.Entity<T>>, newSize: number) => {
|
||||
if (entity.content.ids.length !== newSize) {
|
||||
// Reset Entity State
|
||||
entity.dirtyEntities = [];
|
||||
entity.content.ids = Array(newSize).fill(null);
|
||||
entity.content.entities = {};
|
||||
}
|
||||
};
|
||||
|
||||
range &&
|
||||
builder
|
||||
.addCase(range.pending, (state) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "loading";
|
||||
entity.error = null;
|
||||
})
|
||||
.addCase(range.fulfilled, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
|
||||
const {
|
||||
meta: {
|
||||
arg: { start, length },
|
||||
},
|
||||
payload: { data, total },
|
||||
} = action;
|
||||
|
||||
const keyName = entity.content.keyName as keyof T;
|
||||
|
||||
checkSizeUpdate(entity, total);
|
||||
|
||||
data.forEach((v) => {
|
||||
const key = String(v[keyName]);
|
||||
entity.content.entities[key] = v as Draft<T>;
|
||||
});
|
||||
|
||||
const idsToUpdate = data.map((v) => String(v[keyName]));
|
||||
|
||||
// Remove duplicated ids
|
||||
const pulledSize =
|
||||
total - pullAll(entity.content.ids, idsToUpdate).length;
|
||||
entity.content.ids.push(...Array(pulledSize).fill(null));
|
||||
|
||||
entity.content.ids.splice(start, length, ...idsToUpdate);
|
||||
|
||||
ReducerUtility.updateDirty(entity, idsToUpdate);
|
||||
ReducerUtility.updateDidLoaded(entity, idsToUpdate);
|
||||
})
|
||||
.addCase(range.rejected, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "failed";
|
||||
entity.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
ids &&
|
||||
builder
|
||||
.addCase(ids.pending, (state) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "loading";
|
||||
entity.error = null;
|
||||
})
|
||||
.addCase(ids.fulfilled, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
|
||||
const {
|
||||
meta: { arg },
|
||||
payload: { data, total },
|
||||
} = action;
|
||||
|
||||
const keyName = entity.content.keyName as keyof T;
|
||||
|
||||
checkSizeUpdate(entity, total);
|
||||
|
||||
const idsToAdd = data.map((v) => String(v[keyName]));
|
||||
|
||||
// For new ids, remove null from list and add them
|
||||
const newIds = difference(
|
||||
idsToAdd,
|
||||
entity.content.ids.filter(isString)
|
||||
);
|
||||
const newSize = entity.content.ids.unshift(...newIds);
|
||||
Array(newSize - total)
|
||||
.fill(undefined)
|
||||
.forEach(() => {
|
||||
const idx = entity.content.ids.findIndex(isNull);
|
||||
conditionalLog(idx === -1, "Error when deleting ids from entity");
|
||||
entity.content.ids.splice(idx, 1);
|
||||
});
|
||||
|
||||
data.forEach((v) => {
|
||||
const key = String(v[keyName]);
|
||||
entity.content.entities[key] = v as Draft<T>;
|
||||
});
|
||||
|
||||
const allIds = arg.map(String);
|
||||
|
||||
ReducerUtility.updateDirty(entity, allIds);
|
||||
ReducerUtility.updateDidLoaded(entity, allIds);
|
||||
})
|
||||
.addCase(ids.rejected, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "failed";
|
||||
entity.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
removeIds &&
|
||||
builder.addCase(removeIds, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
conditionalLog(
|
||||
entity.state === "loading",
|
||||
"Try to delete async entity when it's now loading"
|
||||
);
|
||||
|
||||
const idsToDelete = action.payload.map(String);
|
||||
pullAll(entity.content.ids, idsToDelete);
|
||||
ReducerUtility.removeDirty(entity, idsToDelete);
|
||||
ReducerUtility.removeDidLoaded(entity, idsToDelete);
|
||||
|
||||
omit(entity.content.entities, idsToDelete);
|
||||
});
|
||||
|
||||
all &&
|
||||
builder
|
||||
.addCase(all.pending, (state) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "loading";
|
||||
entity.error = null;
|
||||
})
|
||||
.addCase(all.fulfilled, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
|
||||
const {
|
||||
payload: { data, total },
|
||||
} = action;
|
||||
|
||||
conditionalLog(
|
||||
data.length !== total,
|
||||
"Length of data is mismatch with total length"
|
||||
);
|
||||
|
||||
const keyName = entity.content.keyName as keyof T;
|
||||
|
||||
entity.state = "succeeded";
|
||||
entity.dirtyEntities = [];
|
||||
entity.content.ids = data.map((v) => String(v[keyName]));
|
||||
entity.content.entities = data.reduce<
|
||||
Draft<{
|
||||
[id: string]: T;
|
||||
}>
|
||||
>((prev, curr) => {
|
||||
const id = String(curr[keyName]);
|
||||
prev[id] = curr as Draft<T>;
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
const allIds = entity.content.ids.filter(isString);
|
||||
ReducerUtility.updateDidLoaded(entity, allIds);
|
||||
})
|
||||
.addCase(all.rejected, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
entity.state = "failed";
|
||||
entity.error = action.error.message ?? null;
|
||||
});
|
||||
|
||||
dirty &&
|
||||
builder.addCase(dirty, (state, action) => {
|
||||
const entity = getEntity(state);
|
||||
ReducerUtility.markDirty(entity, action.payload.map(String));
|
||||
});
|
||||
|
||||
reset &&
|
||||
builder.addCase(reset, (state) => {
|
||||
const entity = getEntity(state);
|
||||
entity.content.entities = {};
|
||||
entity.content.ids = [];
|
||||
entity.didLoaded = [];
|
||||
entity.dirtyEntities = [];
|
||||
entity.error = null;
|
||||
entity.state = "uninitialized";
|
||||
});
|
||||
}
|
@ -1,10 +1,88 @@
|
||||
export function defaultAOS(): AsyncOrderState<any> {
|
||||
return {
|
||||
updating: true,
|
||||
data: {
|
||||
items: [],
|
||||
order: [],
|
||||
dirty: false,
|
||||
},
|
||||
};
|
||||
import { Draft } from "@reduxjs/toolkit";
|
||||
import { difference, pullAll, uniq } from "lodash";
|
||||
|
||||
export namespace AsyncUtility {
|
||||
export function getDefaultItem<T>(): Async.Item<T> {
|
||||
return {
|
||||
state: "uninitialized",
|
||||
content: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultList<T>(key: keyof T): Async.List<T> {
|
||||
return {
|
||||
state: "uninitialized",
|
||||
keyName: key,
|
||||
dirtyEntities: [],
|
||||
didLoaded: [],
|
||||
content: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultEntity<T>(key: keyof T): Async.Entity<T> {
|
||||
return {
|
||||
state: "uninitialized",
|
||||
dirtyEntities: [],
|
||||
didLoaded: [],
|
||||
content: {
|
||||
keyName: key,
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ReducerUtility {
|
||||
type DirtyType = Draft<Async.Entity<any>> | Draft<Async.List<any>>;
|
||||
export function markDirty<T extends DirtyType>(
|
||||
entity: T,
|
||||
dirtyIds: string[]
|
||||
) {
|
||||
if (entity.state !== "uninitialized" && entity.state !== "loading") {
|
||||
entity.state = "dirty";
|
||||
entity.dirtyEntities.push(...dirtyIds);
|
||||
entity.dirtyEntities = uniq(entity.dirtyEntities);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDirty<T extends DirtyType>(
|
||||
entity: T,
|
||||
updatedIds: string[]
|
||||
) {
|
||||
entity.dirtyEntities = difference(entity.dirtyEntities, updatedIds);
|
||||
if (entity.dirtyEntities.length > 0) {
|
||||
entity.state = "dirty";
|
||||
} else {
|
||||
entity.state = "succeeded";
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDirty<T extends DirtyType>(
|
||||
entity: T,
|
||||
removedIds: string[]
|
||||
) {
|
||||
pullAll(entity.dirtyEntities, removedIds);
|
||||
if (entity.dirtyEntities.length === 0 && entity.state === "dirty") {
|
||||
entity.state = "succeeded";
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDidLoaded<T extends DirtyType>(
|
||||
entity: T,
|
||||
loadedIds: string[]
|
||||
) {
|
||||
entity.didLoaded.push(...loadedIds);
|
||||
entity.didLoaded = uniq(entity.didLoaded);
|
||||
}
|
||||
|
||||
export function removeDidLoaded<T extends DirtyType>(
|
||||
entity: T,
|
||||
removedIds: string[]
|
||||
) {
|
||||
pullAll(entity.didLoaded, removedIds);
|
||||
}
|
||||
}
|
||||
|
@ -1,181 +0,0 @@
|
||||
import { difference, has, isArray, isNull, isNumber, uniqBy } from "lodash";
|
||||
import { Action } from "redux-actions";
|
||||
import { conditionalLog } from "../../utilites/logger";
|
||||
import { AsyncAction } from "../types";
|
||||
|
||||
export function updateAsyncState<Payload>(
|
||||
action: AsyncAction<Payload>,
|
||||
defVal: Readonly<Payload>
|
||||
): AsyncState<Payload> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
updating: true,
|
||||
data: defVal,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
data: defVal,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
updating: false,
|
||||
error: undefined,
|
||||
data: action.payload.item as Payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function updateOrderIdState<T extends LooseObject>(
|
||||
action: AsyncAction<AsyncDataWrapper<T>>,
|
||||
state: AsyncOrderState<T>,
|
||||
id: ItemIdType<T>
|
||||
): AsyncOrderState<T> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
data: {
|
||||
...state.data,
|
||||
dirty: true,
|
||||
},
|
||||
updating: true,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
data: {
|
||||
...state.data,
|
||||
dirty: true,
|
||||
},
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
};
|
||||
} else {
|
||||
const { data, total } = action.payload.item as AsyncDataWrapper<T>;
|
||||
const { parameters } = action.payload;
|
||||
const [start, length] = parameters;
|
||||
|
||||
// Convert item list to object
|
||||
const newItems = data.reduce<IdState<T>>(
|
||||
(prev, curr) => {
|
||||
const tid = curr[id];
|
||||
prev[tid] = curr;
|
||||
return prev;
|
||||
},
|
||||
{ ...state.data.items }
|
||||
);
|
||||
|
||||
let newOrder = [...state.data.order];
|
||||
|
||||
const countDist = total - newOrder.length;
|
||||
if (countDist > 0) {
|
||||
newOrder = Array(countDist).fill(null).concat(newOrder);
|
||||
} else if (countDist < 0) {
|
||||
// Completely drop old data if list has shrinked
|
||||
newOrder = Array(total).fill(null);
|
||||
}
|
||||
|
||||
const idList = newOrder.filter(isNumber);
|
||||
|
||||
const dataOrder: number[] = data.map((v) => v[id]);
|
||||
|
||||
if (typeof start === "number" && typeof length === "number") {
|
||||
newOrder.splice(start, length, ...dataOrder);
|
||||
} else if (isArray(start)) {
|
||||
// Find the null values and delete them, insert new values to the front of array
|
||||
const addition = difference(dataOrder, idList);
|
||||
let addCount = addition.length;
|
||||
newOrder.unshift(...addition);
|
||||
|
||||
newOrder = newOrder.flatMap((v) => {
|
||||
if (isNull(v) && addCount > 0) {
|
||||
--addCount;
|
||||
return [];
|
||||
} else {
|
||||
return [v];
|
||||
}
|
||||
}, []);
|
||||
|
||||
conditionalLog(
|
||||
addCount !== 0,
|
||||
"Error when replacing item in OrderIdState"
|
||||
);
|
||||
} else if (parameters.length === 0) {
|
||||
// TODO: Delete me -> Full Update
|
||||
newOrder = dataOrder;
|
||||
}
|
||||
|
||||
return {
|
||||
updating: false,
|
||||
data: {
|
||||
dirty: true,
|
||||
items: newItems,
|
||||
order: newOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteOrderListItemBy<T extends LooseObject>(
|
||||
action: Action<number[]>,
|
||||
state: AsyncOrderState<T>
|
||||
): AsyncOrderState<T> {
|
||||
const ids = action.payload;
|
||||
const { items, order } = state.data;
|
||||
const newItems = { ...items };
|
||||
ids.forEach((v) => {
|
||||
if (has(newItems, v)) {
|
||||
delete newItems[v];
|
||||
}
|
||||
});
|
||||
const newOrder = difference(order, ids);
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
dirty: true,
|
||||
items: newItems,
|
||||
order: newOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteAsyncListItemBy<T extends LooseObject>(
|
||||
action: Action<number[]>,
|
||||
state: AsyncState<T[]>,
|
||||
match: ItemIdType<T>
|
||||
): AsyncState<T[]> {
|
||||
const ids = new Set(action.payload);
|
||||
const data = [...state.data].filter((v) => !ids.has(v[match]));
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAsyncList<T, ID extends keyof T>(
|
||||
action: AsyncAction<T[]>,
|
||||
state: AsyncState<T[]>,
|
||||
match: ID
|
||||
): AsyncState<T[]> {
|
||||
if (action.payload.loading) {
|
||||
return {
|
||||
...state,
|
||||
updating: true,
|
||||
};
|
||||
} else if (action.error !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
updating: false,
|
||||
error: action.payload.item as Error,
|
||||
};
|
||||
} else {
|
||||
const olds = state.data as T[];
|
||||
const news = action.payload.item as T[];
|
||||
|
||||
const result = uniqBy([...news, ...olds], match);
|
||||
|
||||
return {
|
||||
updating: false,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import Socketio from ".";
|
||||
import { log } from "../utilites/logger";
|
||||
|
||||
export function useSocketIOReducer(reducer: SocketIO.Reducer) {
|
||||
useEffect(() => {
|
||||
Socketio.addReducer(reducer);
|
||||
log("info", "listening to SocketIO event", reducer.key);
|
||||
return () => {
|
||||
Socketio.removeReducer(reducer);
|
||||
};
|
||||
}, [reducer]);
|
||||
}
|
||||
|
||||
export function useWrapToOptionalId(
|
||||
fn: (id: number[]) => void
|
||||
): SocketIO.ActionFn<number> {
|
||||
return useCallback(
|
||||
(id?: number[]) => {
|
||||
if (id) {
|
||||
fn(id);
|
||||
}
|
||||
},
|
||||
[fn]
|
||||
);
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
import { createAction } from "redux-actions";
|
||||
import {
|
||||
badgeUpdateAll,
|
||||
bootstrap,
|
||||
movieDeleteItems,
|
||||
movieDeleteWantedItems,
|
||||
movieUpdateList,
|
||||
movieUpdateWantedList,
|
||||
seriesDeleteItems,
|
||||
seriesDeleteWantedItems,
|
||||
seriesUpdateList,
|
||||
seriesUpdateWantedList,
|
||||
siteAddNotifications,
|
||||
siteAddProgress,
|
||||
siteInitializationFailed,
|
||||
siteRemoveProgress,
|
||||
siteUpdateOffline,
|
||||
systemUpdateLanguagesAll,
|
||||
systemUpdateSettings,
|
||||
} from "../@redux/actions";
|
||||
import reduxStore from "../@redux/store";
|
||||
|
||||
function bindToReduxStore(
|
||||
fn: (ids?: number[]) => any
|
||||
): SocketIO.ActionFn<number> {
|
||||
return (ids?: number[]) => reduxStore.dispatch(fn(ids));
|
||||
}
|
||||
|
||||
export function createDeleteAction(type: string): SocketIO.ActionFn<number> {
|
||||
return createAction(type, (id?: number[]) => id ?? []);
|
||||
}
|
||||
|
||||
export function createDefaultReducer(): SocketIO.Reducer[] {
|
||||
return [
|
||||
{
|
||||
key: "connect",
|
||||
any: () => reduxStore.dispatch(siteUpdateOffline(false)),
|
||||
},
|
||||
{
|
||||
key: "connect",
|
||||
any: () => reduxStore.dispatch<any>(bootstrap()),
|
||||
},
|
||||
{
|
||||
key: "connect_error",
|
||||
any: () => {
|
||||
const initialized = reduxStore.getState().site.initialized;
|
||||
if (initialized === true) {
|
||||
reduxStore.dispatch(siteUpdateOffline(true));
|
||||
} else {
|
||||
reduxStore.dispatch(siteInitializationFailed());
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "disconnect",
|
||||
any: () => reduxStore.dispatch(siteUpdateOffline(true)),
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
update: (msg) => {
|
||||
if (msg) {
|
||||
const notifications = msg.map<ReduxStore.Notification>((message) => ({
|
||||
message,
|
||||
type: "info",
|
||||
id: "backend-message",
|
||||
timeout: 5 * 1000,
|
||||
}));
|
||||
|
||||
reduxStore.dispatch(siteAddNotifications(notifications));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
update: (progress) => {
|
||||
if (progress) {
|
||||
reduxStore.dispatch(siteAddProgress(progress));
|
||||
}
|
||||
},
|
||||
delete: (ids) => {
|
||||
setTimeout(() => {
|
||||
ids?.forEach((id) => {
|
||||
reduxStore.dispatch(siteRemoveProgress(id));
|
||||
});
|
||||
}, 3 * 1000);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "series",
|
||||
update: bindToReduxStore(seriesUpdateList),
|
||||
delete: bindToReduxStore(seriesDeleteItems),
|
||||
},
|
||||
{
|
||||
key: "movie",
|
||||
update: bindToReduxStore(movieUpdateList),
|
||||
delete: bindToReduxStore(movieDeleteItems),
|
||||
},
|
||||
{
|
||||
key: "episode-wanted",
|
||||
update: (ids: number[] | undefined) => {
|
||||
if (ids) {
|
||||
reduxStore.dispatch(seriesUpdateWantedList(ids) as any);
|
||||
}
|
||||
},
|
||||
delete: bindToReduxStore(seriesDeleteWantedItems),
|
||||
},
|
||||
{
|
||||
key: "movie-wanted",
|
||||
update: (ids: number[] | undefined) => {
|
||||
if (ids) {
|
||||
reduxStore.dispatch(movieUpdateWantedList(ids) as any);
|
||||
}
|
||||
},
|
||||
delete: bindToReduxStore(movieDeleteWantedItems),
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
any: bindToReduxStore(systemUpdateSettings),
|
||||
},
|
||||
{
|
||||
key: "languages",
|
||||
any: bindToReduxStore(systemUpdateLanguagesAll),
|
||||
},
|
||||
{
|
||||
key: "badges",
|
||||
any: bindToReduxStore(badgeUpdateAll),
|
||||
},
|
||||
];
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
declare namespace Async {
|
||||
type State = "loading" | "succeeded" | "failed" | "dirty" | "uninitialized";
|
||||
|
||||
type IdType = number | string;
|
||||
|
||||
type Base<T> = {
|
||||
state: State;
|
||||
content: T;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type List<T> = Base<T[]> & {
|
||||
keyName: keyof T;
|
||||
dirtyEntities: string[];
|
||||
didLoaded: string[];
|
||||
};
|
||||
|
||||
type Item<T> = Base<T | null>;
|
||||
|
||||
type Entity<T> = Base<EntityStruct<T>> & {
|
||||
dirtyEntities: string[];
|
||||
didLoaded: string[];
|
||||
};
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
declare namespace Server {
|
||||
interface Notification {
|
||||
type: "error" | "warning" | "info";
|
||||
id: string;
|
||||
message: string;
|
||||
timeout: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace Site {
|
||||
interface Progress {
|
||||
id: string;
|
||||
header: string;
|
||||
name: string;
|
||||
value: number;
|
||||
count: number;
|
||||
}
|
||||
}
|
@ -0,0 +1,240 @@
|
||||
import {
|
||||
faBug,
|
||||
faCircleNotch,
|
||||
faExclamationTriangle,
|
||||
faInfoCircle,
|
||||
faStream,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Overlay,
|
||||
ProgressBar,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
||||
import { useReduxStore } from "../@redux/hooks/base";
|
||||
import { BuildKey, useIsArrayExtended } from "../utilities";
|
||||
import "./notification.scss";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Working,
|
||||
Failed,
|
||||
}
|
||||
|
||||
function useTotalProgress(progress: Site.Progress[]) {
|
||||
return useMemo(() => {
|
||||
const { value, count } = progress.reduce(
|
||||
(prev, { value, count }) => {
|
||||
prev.value += value;
|
||||
prev.count += count;
|
||||
return prev;
|
||||
},
|
||||
{ value: 0, count: 0 }
|
||||
);
|
||||
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return (value + 0.001) / count;
|
||||
}
|
||||
}, [progress]);
|
||||
}
|
||||
|
||||
function useHasErrorNotification(notifications: Server.Notification[]) {
|
||||
return useMemo(
|
||||
() => notifications.find((v) => v.type !== "info") !== undefined,
|
||||
[notifications]
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationCenter: FunctionComponent = () => {
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hasNew, setHasNew] = useState(false);
|
||||
|
||||
const hasNewProgress = useIsArrayExtended(progress);
|
||||
const hasNewNotifications = useIsArrayExtended(notifications);
|
||||
useDidUpdate(() => {
|
||||
if (hasNewNotifications || hasNewProgress) {
|
||||
setHasNew(true);
|
||||
}
|
||||
}, [hasNewProgress, hasNewNotifications]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (progress.length === 0 && notifications.length === 0) {
|
||||
setHasNew(false);
|
||||
}
|
||||
}, [progress.length, notifications.length]);
|
||||
|
||||
const [btnState, setBtnState] = useState(State.Idle);
|
||||
|
||||
const totalProgress = useTotalProgress(progress);
|
||||
const hasError = useHasErrorNotification(notifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
setBtnState(State.Failed);
|
||||
} else if (totalProgress > 0 && totalProgress < 1.0) {
|
||||
setBtnState(State.Working);
|
||||
} else {
|
||||
setBtnState(State.Idle);
|
||||
}
|
||||
}, [totalProgress, hasError]);
|
||||
|
||||
const iconProps = useMemo<FontAwesomeIconProps>(() => {
|
||||
switch (btnState) {
|
||||
case State.Idle:
|
||||
return {
|
||||
icon: faStream,
|
||||
};
|
||||
case State.Working:
|
||||
return {
|
||||
icon: faCircleNotch,
|
||||
spin: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: faExclamationTriangle,
|
||||
};
|
||||
}
|
||||
}, [btnState]);
|
||||
|
||||
const content = useMemo<React.ReactNode>(() => {
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="notifications-header">
|
||||
{notifications.length > 0 ? "Notifications" : "No Notifications"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...notifications.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
|
||||
<Notification {...v}></Notification>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="background-task-header">
|
||||
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...progress.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
|
||||
<Progress {...v}></Progress>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [progress, notifications]);
|
||||
|
||||
const onToggleClick = useCallback(() => {
|
||||
setHasNew(false);
|
||||
}, []);
|
||||
|
||||
// Tooltip Controller
|
||||
const [showTooltip, setTooltip] = useState(false);
|
||||
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
|
||||
useDidUpdate(() => {
|
||||
if (notifier.content) {
|
||||
setTooltip(true);
|
||||
}
|
||||
}, [notifier.timestamp]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Dropdown
|
||||
onClick={onToggleClick}
|
||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
||||
ref={dropdownRef}
|
||||
alignRight
|
||||
>
|
||||
<Dropdown.Toggle as={Button} className="dropdown-hidden">
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
|
||||
{(props) => {
|
||||
return (
|
||||
<Tooltip id="new-notification-tip" {...props}>
|
||||
{notifier.content}
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</Overlay>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const Notification: FunctionComponent<Server.Notification> = ({
|
||||
type,
|
||||
message,
|
||||
}) => {
|
||||
const icon = useMemo<IconDefinition>(() => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return faInfoCircle;
|
||||
case "warning":
|
||||
return faExclamationTriangle;
|
||||
default:
|
||||
return faBug;
|
||||
}
|
||||
}, [type]);
|
||||
return (
|
||||
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
|
||||
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
|
||||
<span className="text-dark small">{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Progress: FunctionComponent<Site.Progress> = ({
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
header,
|
||||
}) => {
|
||||
const isCompleted = value / count > 1;
|
||||
const displayValue = Math.min(count, value + 1);
|
||||
return (
|
||||
<div className="notification-center-progress d-flex flex-column">
|
||||
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
|
||||
{header}
|
||||
</p>
|
||||
<p className="progress-name m-0 small text-secondary">
|
||||
{isCompleted ? "Completed successfully" : name}
|
||||
</p>
|
||||
<ProgressBar
|
||||
className="mt-2"
|
||||
animated={!isCompleted}
|
||||
now={displayValue / count}
|
||||
max={1}
|
||||
label={`${displayValue}/${count}`}
|
||||
></ProgressBar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
@ -0,0 +1,52 @@
|
||||
@import "../@scss/variable.scss";
|
||||
@import "../@scss/bazarr.scss";
|
||||
|
||||
@function theme-color($key: "primary") {
|
||||
@return map-get($theme-colors, $key);
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
&.new-item {
|
||||
&::after {
|
||||
position: absolute;
|
||||
background-color: red;
|
||||
content: "";
|
||||
border-radius: 50%;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
right: 10%;
|
||||
top: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
$content-width: 14rem;
|
||||
|
||||
.notification-center-progress {
|
||||
width: $content-width;
|
||||
max-width: $content-width;
|
||||
|
||||
.progress-name {
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.progress .progress-bar {
|
||||
text-shadow: -2px -2px 5px theme-color("primary"),
|
||||
2px -2px 5px theme-color("primary"), -2px 2px 5px theme-color("primary"),
|
||||
2px 2px 5px theme-color("primary");
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-center-notification {
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
width: $content-width;
|
||||
max-width: $content-width;
|
||||
}
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import {
|
||||
faExclamationTriangle,
|
||||
faPaperPlane,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { capitalize } from "lodash";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { ProgressBar, Toast } from "react-bootstrap";
|
||||
import {
|
||||
siteRemoveNotifications,
|
||||
siteRemoveProgress,
|
||||
} from "../../@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
|
||||
import "./style.scss";
|
||||
|
||||
export interface NotificationContainerProps {}
|
||||
|
||||
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
|
||||
const { progress, notifications } = useReduxStore((s) => s.site);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const progressItems = progress.map((v) => (
|
||||
<ProgressToast key={v.id} {...v}></ProgressToast>
|
||||
));
|
||||
|
||||
const notificationItems = notifications.map((v) => (
|
||||
<NotificationToast key={v.id} {...v}></NotificationToast>
|
||||
));
|
||||
|
||||
return [...progressItems, ...notificationItems];
|
||||
}, [notifications, progress]);
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="toast-container">{items}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MessageHolderProps = ReduxStore.Notification & {};
|
||||
|
||||
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
|
||||
const { message, type, id, timeout } = props;
|
||||
const removeNotification = useReduxAction(siteRemoveNotifications);
|
||||
|
||||
const remove = useCallback(() => removeNotification(id), [
|
||||
removeNotification,
|
||||
id,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(remove, timeout);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [props, remove, timeout]);
|
||||
|
||||
return (
|
||||
<Toast onClose={remove} animation={false}>
|
||||
<Toast.Header>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1"
|
||||
icon={faExclamationTriangle}
|
||||
></FontAwesomeIcon>
|
||||
<strong className="mr-auto">{capitalize(type)}</strong>
|
||||
</Toast.Header>
|
||||
<Toast.Body>{message}</Toast.Body>
|
||||
</Toast>
|
||||
);
|
||||
};
|
||||
|
||||
type ProgressHolderProps = ReduxStore.Progress & {};
|
||||
|
||||
const ProgressToast: FunctionComponent<ProgressHolderProps> = ({
|
||||
id,
|
||||
header,
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
}) => {
|
||||
const removeProgress = useReduxAction(siteRemoveProgress);
|
||||
const remove = useCallback(() => removeProgress(id), [removeProgress, id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(remove, 10 * 1000);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [value, remove]);
|
||||
|
||||
const incomplete = value / count < 1;
|
||||
|
||||
return (
|
||||
<Toast onClose={remove}>
|
||||
<Toast.Header closeButton={false}>
|
||||
<FontAwesomeIcon className="mr-2" icon={faPaperPlane}></FontAwesomeIcon>
|
||||
<span className="mr-auto">{header}</span>
|
||||
</Toast.Header>
|
||||
<Toast.Body>
|
||||
<span>{name}</span>
|
||||
<ProgressBar
|
||||
className="my-1"
|
||||
animated={incomplete}
|
||||
now={value / count}
|
||||
max={1}
|
||||
label={`${value}/${count}`}
|
||||
></ProgressBar>
|
||||
</Toast.Body>
|
||||
</Toast>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationContainer;
|
@ -1,46 +0,0 @@
|
||||
@import "../../@scss/variable.scss";
|
||||
@import "../../@scss/bazarr.scss";
|
||||
|
||||
@function theme-color($key: "primary") {
|
||||
@return map-get($theme-colors, $key);
|
||||
}
|
||||
|
||||
.alert-container {
|
||||
position: fixed;
|
||||
display: block;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: $header-height;
|
||||
|
||||
z-index: 9999;
|
||||
|
||||
.toast-container {
|
||||
padding: 1rem;
|
||||
|
||||
.toast {
|
||||
width: 16rem;
|
||||
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
|
||||
.toast-body {
|
||||
span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
.progress {
|
||||
.progress-bar {
|
||||
text-shadow: -2px -2px 5px theme-color("primary"),
|
||||
2px -2px 5px theme-color("primary"),
|
||||
-2px 2px 5px theme-color("primary"),
|
||||
2px 2px 5px theme-color("primary");
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
import {
|
||||
faAdjust,
|
||||
faBriefcase,
|
||||
faCloudUploadAlt,
|
||||
faHdd,
|
||||
faSearch,
|
||||
faSync,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { FunctionComponent, useMemo, useState } from "react";
|
||||
import { Alert, Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { dispatchTask } from "../../@modules/task";
|
||||
import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
|
||||
import { createTask } from "../../@modules/task/utilities";
|
||||
import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
|
||||
import { SeriesApi } from "../../apis";
|
||||
import {
|
||||
ContentHeader,
|
||||
ItemEditorModal,
|
||||
LoadingIndicator,
|
||||
SeriesUploadModal,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import { RouterEmptyPath } from "../../special-pages/404";
|
||||
import { useOnLoadedOnce } from "../../utilities";
|
||||
import ItemOverview from "../generic/ItemOverview";
|
||||
import Table from "./table";
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Params> {}
|
||||
|
||||
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
|
||||
const { match } = props;
|
||||
const id = Number.parseInt(match.params.id);
|
||||
const series = useSerieBy(id);
|
||||
const episodes = useEpisodesBy(id);
|
||||
const serie = series.content;
|
||||
|
||||
const available = episodes.content.length !== 0;
|
||||
|
||||
const details = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: faHdd,
|
||||
text: `${serie?.episodeFileCount} files`,
|
||||
},
|
||||
{
|
||||
icon: faAdjust,
|
||||
text: serie?.seriesType ?? "",
|
||||
},
|
||||
],
|
||||
[serie]
|
||||
);
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const [valid, setValid] = useState(true);
|
||||
|
||||
useOnLoadedOnce(() => {
|
||||
if (series.content === null) {
|
||||
setValid(false);
|
||||
}
|
||||
}, series);
|
||||
|
||||
const profile = useProfileBy(series.content?.profileId);
|
||||
|
||||
const hasTask = useIsAnyTaskRunningWithId([
|
||||
...episodes.content.map((v) => v.sonarrEpisodeId),
|
||||
id,
|
||||
]);
|
||||
|
||||
if (isNaN(id) || !valid) {
|
||||
return <Redirect to={RouterEmptyPath}></Redirect>;
|
||||
}
|
||||
|
||||
if (!serie) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{serie.title} - Bazarr (Series)</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Group pos="start">
|
||||
<ContentHeader.Button
|
||||
icon={faSync}
|
||||
disabled={!available || hasTask}
|
||||
onClick={() => {
|
||||
const task = createTask(
|
||||
serie.title,
|
||||
id,
|
||||
SeriesApi.action.bind(SeriesApi),
|
||||
{
|
||||
action: "scan-disk",
|
||||
seriesid: id,
|
||||
}
|
||||
);
|
||||
dispatchTask("Scaning disk...", [task], "Scaning...");
|
||||
}}
|
||||
>
|
||||
Scan Disk
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faSearch}
|
||||
onClick={() => {
|
||||
const task = createTask(
|
||||
serie.title,
|
||||
id,
|
||||
SeriesApi.action.bind(SeriesApi),
|
||||
{
|
||||
action: "search-missing",
|
||||
seriesid: id,
|
||||
}
|
||||
);
|
||||
dispatchTask("Searching subtitles...", [task], "Searching...");
|
||||
}}
|
||||
disabled={
|
||||
serie.episodeFileCount === 0 ||
|
||||
serie.profileId === null ||
|
||||
!available
|
||||
}
|
||||
>
|
||||
Search
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button
|
||||
disabled={serie.episodeFileCount === 0 || !available || hasTask}
|
||||
icon={faBriefcase}
|
||||
onClick={() => showModal("tools", episodes.content)}
|
||||
>
|
||||
Tools
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
disabled={
|
||||
serie.episodeFileCount === 0 ||
|
||||
serie.profileId === null ||
|
||||
!available
|
||||
}
|
||||
icon={faCloudUploadAlt}
|
||||
onClick={() => showModal("upload", serie)}
|
||||
>
|
||||
Upload
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faWrench}
|
||||
disabled={hasTask}
|
||||
onClick={() => showModal("edit", serie)}
|
||||
>
|
||||
Edit Series
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Alert
|
||||
className="w-100 m-0 py-2"
|
||||
show={hasTask}
|
||||
style={{ borderRadius: 0 }}
|
||||
variant="light"
|
||||
>
|
||||
A background task is running for this show, actions are unavailable
|
||||
</Alert>
|
||||
</Row>
|
||||
<Row>
|
||||
<ItemOverview item={serie} details={details}></ItemOverview>
|
||||
</Row>
|
||||
<Row>
|
||||
<Table
|
||||
serie={series}
|
||||
episodes={episodes}
|
||||
profile={profile}
|
||||
disabled={hasTask}
|
||||
></Table>
|
||||
</Row>
|
||||
<ItemEditorModal
|
||||
modalKey="edit"
|
||||
submit={(form) => SeriesApi.modify(form)}
|
||||
></ItemEditorModal>
|
||||
<SeriesUploadModal
|
||||
modalKey="upload"
|
||||
episodes={episodes.content}
|
||||
></SeriesUploadModal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(SeriesEpisodesView);
|
@ -0,0 +1,45 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
|
||||
import { RouterEmptyPath } from "../special-pages/404";
|
||||
import Episodes from "./Episodes";
|
||||
import MovieDetail from "./MovieDetail";
|
||||
import Movies from "./Movies";
|
||||
import Series from "./Series";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Router: FunctionComponent<Props> = () => {
|
||||
const radarr = useIsRadarrEnabled();
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
{radarr && (
|
||||
<Route exact path="/movies">
|
||||
<Movies></Movies>
|
||||
</Route>
|
||||
)}
|
||||
{radarr && (
|
||||
<Route path="/movies/:id">
|
||||
<MovieDetail></MovieDetail>
|
||||
</Route>
|
||||
)}
|
||||
{sonarr && (
|
||||
<Route exact path="/series">
|
||||
<Series></Series>
|
||||
</Route>
|
||||
)}
|
||||
{sonarr && (
|
||||
<Route path="/series/:id">
|
||||
<Episodes></Episodes>
|
||||
</Route>
|
||||
)}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
@ -1,7 +1,7 @@
|
||||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { AsyncButton } from "../components";
|
||||
import { AsyncButton } from "../../components";
|
||||
|
||||
interface Props {
|
||||
history: History.Base;
|
@ -1,21 +0,0 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import Movie from ".";
|
||||
import MovieDetail from "./Detail";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Router: FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/movies">
|
||||
<Movie></Movie>
|
||||
</Route>
|
||||
<Route path="/movies/:id">
|
||||
<MovieDetail></MovieDetail>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
@ -1,160 +0,0 @@
|
||||
import {
|
||||
faAdjust,
|
||||
faBriefcase,
|
||||
faCloudUploadAlt,
|
||||
faHdd,
|
||||
faSearch,
|
||||
faSync,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
|
||||
import { SeriesApi } from "../../apis";
|
||||
import {
|
||||
ContentHeader,
|
||||
ItemEditorModal,
|
||||
LoadingIndicator,
|
||||
SeriesUploadModal,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import ItemOverview from "../../generic/ItemOverview";
|
||||
import { RouterEmptyPath } from "../../special-pages/404";
|
||||
import { useOnLoadingFinish } from "../../utilites";
|
||||
import Table from "./table";
|
||||
|
||||
interface Params {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<Params> {}
|
||||
|
||||
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
|
||||
const { match } = props;
|
||||
const id = Number.parseInt(match.params.id);
|
||||
const [serie] = useSerieBy(id);
|
||||
const item = serie.data;
|
||||
|
||||
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId);
|
||||
|
||||
const available = episodes.data.length !== 0;
|
||||
|
||||
const details = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: faHdd,
|
||||
text: `${item?.episodeFileCount} files`,
|
||||
},
|
||||
{
|
||||
icon: faAdjust,
|
||||
text: item?.seriesType ?? "",
|
||||
},
|
||||
],
|
||||
[item]
|
||||
);
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const [valid, setValid] = useState(true);
|
||||
|
||||
const validator = useCallback(() => {
|
||||
if (serie.data === null) {
|
||||
setValid(false);
|
||||
}
|
||||
}, [serie.data]);
|
||||
|
||||
useOnLoadingFinish(serie, validator);
|
||||
|
||||
const profile = useProfileBy(serie.data?.profileId);
|
||||
|
||||
if (isNaN(id) || !valid) {
|
||||
return <Redirect to={RouterEmptyPath}></Redirect>;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{item.title} - Bazarr (Series)</title>
|
||||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Group pos="start">
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSync}
|
||||
disabled={!available}
|
||||
promise={() =>
|
||||
SeriesApi.action({ action: "scan-disk", seriesid: id })
|
||||
}
|
||||
>
|
||||
Scan Disk
|
||||
</ContentHeader.AsyncButton>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faSearch}
|
||||
promise={() =>
|
||||
SeriesApi.action({ action: "search-missing", seriesid: id })
|
||||
}
|
||||
disabled={
|
||||
item.episodeFileCount === 0 ||
|
||||
item.profileId === null ||
|
||||
!available
|
||||
}
|
||||
>
|
||||
Search
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button
|
||||
disabled={item.episodeFileCount === 0 || !available}
|
||||
icon={faBriefcase}
|
||||
onClick={() => showModal("tools", episodes.data)}
|
||||
>
|
||||
Tools
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
disabled={
|
||||
item.episodeFileCount === 0 ||
|
||||
item.profileId === null ||
|
||||
!available
|
||||
}
|
||||
icon={faCloudUploadAlt}
|
||||
onClick={() => showModal("upload", item)}
|
||||
>
|
||||
Upload
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faWrench}
|
||||
onClick={() => showModal("edit", item)}
|
||||
>
|
||||
Edit Series
|
||||
</ContentHeader.Button>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<ItemOverview item={item} details={details}></ItemOverview>
|
||||
</Row>
|
||||
<Row>
|
||||
<Table episodes={episodes} profile={profile}></Table>
|
||||
</Row>
|
||||
<ItemEditorModal
|
||||
modalKey="edit"
|
||||
submit={(form) => SeriesApi.modify(form)}
|
||||
></ItemEditorModal>
|
||||
<SeriesUploadModal
|
||||
modalKey="upload"
|
||||
episodes={episodes.data}
|
||||
></SeriesUploadModal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(SeriesEpisodesView);
|
@ -1,21 +0,0 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import Series from ".";
|
||||
import Episodes from "./Episodes";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Router: FunctionComponent<Props> = () => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/series">
|
||||
<Series></Series>
|
||||
</Route>
|
||||
<Route path="/series/:id">
|
||||
<Episodes></Episodes>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue