mirror of https://git.sr.ht/~ashkeel/strimertul
switch to biome for format/linting, add auto lints
This commit is contained in:
parent
d276b734bf
commit
af1f198eba
|
@ -1,46 +0,0 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh', '@typescript-eslint', 'import'],
|
||||
extends: [
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'prettier',
|
||||
],
|
||||
root: true,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
extraFileExtensions: ['.cjs'],
|
||||
},
|
||||
rules: {
|
||||
'no-console': 0,
|
||||
'import/extensions': 0,
|
||||
'no-use-before-define': 'off',
|
||||
'no-shadow': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-void': ['error', { allowAsStatement: true }],
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
'default-case': 'off',
|
||||
'consistent-return': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unsafe-return': ['error'],
|
||||
'@typescript-eslint/switch-exhaustiveness-check': ['error'],
|
||||
'@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
|
||||
'react-refresh/only-export-components': 'warn'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
moduleDirectory: ['node_modules', 'src/'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
ignorePatterns: ['OLD/*', 'wailsjs/*', 'dist/*'],
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"linter": {
|
||||
"ignore": ["wailsjs/**"],
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"hooks": [
|
||||
{ "name": "useDispatch", "stableResult": true },
|
||||
{ "name": "useKilovoltClient", "stableResult": true },
|
||||
{ "name": "useAppDispatch", "stableResult": true },
|
||||
{ "name": "useNavigate", "stableResult": true },
|
||||
{ "name": "useTranslation", "stableResult": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -43,22 +43,15 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"format": "biome format ./src",
|
||||
"lint": "biome lint ./src"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Chrome version"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"prettier": "^3.0.3",
|
||||
"@biomejs/biome": "1.7.2",
|
||||
"rimraf": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
894313fcc17ff294db3c3171003cb162
|
||||
67f333a2779a971376eeb65b025f5696
|
|
@ -54,40 +54,22 @@ export const unwrapEvent = (message: EventSubNotification) =>
|
|||
type: message.subscription.type,
|
||||
subscription: message.subscription,
|
||||
event: message.event,
|
||||
} as EventSubMessage);
|
||||
}) as EventSubMessage;
|
||||
|
||||
interface TypedEventSubNotification<
|
||||
T extends EventSubNotificationType,
|
||||
Payload,
|
||||
> {
|
||||
interface TypedEventSubNotification<T extends EventSubNotificationType, Payload> {
|
||||
type: T;
|
||||
subscription: EventSubSubscription;
|
||||
event: Payload;
|
||||
}
|
||||
|
||||
export type EventSubMessage =
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ChannelUpdated,
|
||||
ChannelUpdatedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.UserUpdated,
|
||||
UserUpdatedEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.ChannelUpdated, ChannelUpdatedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.UserUpdated, UserUpdatedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Cheered, CheerEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Raided, RaidEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardAdded,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardRemoved,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardUpdated,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.CustomRewardAdded, ChannelRewardEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.CustomRewardRemoved, ChannelRewardEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.CustomRewardUpdated, ChannelRewardEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardRedemptionAdded,
|
||||
ChannelRedemptionEventData<false>
|
||||
|
@ -96,83 +78,29 @@ export type EventSubMessage =
|
|||
EventSubNotificationType.CustomRewardRedemptionUpdated,
|
||||
ChannelRedemptionEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.Followed,
|
||||
FollowEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Followed, FollowEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.GoalBegan, GoalEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.GoalEnded,
|
||||
GoalEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.GoalProgress,
|
||||
GoalEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainBegan,
|
||||
HypeTrainEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainProgress,
|
||||
HypeTrainEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainEnded,
|
||||
HypeTrainEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ModeratorAdded,
|
||||
ModeratorEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ModeratorRemoved,
|
||||
ModeratorEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollBegan,
|
||||
PollEventData<false>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollProgress,
|
||||
PollEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollEnded,
|
||||
PollEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionBegan,
|
||||
PredictionEventData<false>
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.GoalEnded, GoalEndedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.GoalProgress, GoalEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.HypeTrainBegan, HypeTrainEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.HypeTrainProgress, HypeTrainEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.HypeTrainEnded, HypeTrainEndedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.ModeratorAdded, ModeratorEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.ModeratorRemoved, ModeratorEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PollBegan, PollEventData<false>>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PollProgress, PollEventData<true>>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PollEnded, PollEndedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PredictionBegan, PredictionEventData<false>>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionProgress,
|
||||
PredictionEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionLocked,
|
||||
PredictionLockedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionEnded,
|
||||
PredictionEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.StreamWentOffline,
|
||||
StreamEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.StreamWentOnline,
|
||||
StreamWentOnlineEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.Subscription,
|
||||
SubscriptionEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.SubscriptionEnded,
|
||||
SubscriptionEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PredictionLocked, PredictionLockedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.PredictionEnded, PredictionEndedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.StreamWentOffline, StreamEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.StreamWentOnline, StreamWentOnlineEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Subscription, SubscriptionEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.SubscriptionEnded, SubscriptionEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.SubscriptionGifted,
|
||||
SubscriptionGiftedEventData
|
||||
|
@ -181,14 +109,8 @@ export type EventSubMessage =
|
|||
EventSubNotificationType.SubscriptionWithMessage,
|
||||
SubscriptionMessageEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ViewerBanned,
|
||||
UserBannedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ViewerUnbanned,
|
||||
UserUnbannedEventData
|
||||
>;
|
||||
| TypedEventSubNotification<EventSubNotificationType.ViewerBanned, UserBannedEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.ViewerUnbanned, UserUnbannedEventData>;
|
||||
|
||||
export interface StreamEventData {
|
||||
broadcaster_user_id: string;
|
||||
|
@ -297,8 +219,7 @@ export interface ChannelRewardEventData extends StreamEventData {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChannelRedemptionEventData<Updated extends boolean>
|
||||
extends StreamEventData {
|
||||
export interface ChannelRedemptionEventData<Updated extends boolean> extends StreamEventData {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
|
@ -382,8 +303,7 @@ interface PollBaseData<Running extends boolean> extends StreamEventData {
|
|||
started_at: string;
|
||||
}
|
||||
|
||||
export interface PollEventData<Running extends boolean>
|
||||
extends PollBaseData<Running> {
|
||||
export interface PollEventData<Running extends boolean> extends PollBaseData<Running> {
|
||||
started_at: string;
|
||||
ends_at: string;
|
||||
}
|
||||
|
@ -422,8 +342,7 @@ interface PredictionBaseData<Running extends boolean> extends StreamEventData {
|
|||
: UnorderedTuple<Outcome<'blue'>, Outcome<'pink'>>;
|
||||
}
|
||||
|
||||
export interface PredictionEventData<Running extends boolean>
|
||||
extends PredictionBaseData<Running> {
|
||||
export interface PredictionEventData<Running extends boolean> extends PredictionBaseData<Running> {
|
||||
locks_at: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { ExtensionEntry } from '~/store/extensions/reducer';
|
||||
import type { ExtensionEntry } from '~/store/extensions/reducer';
|
||||
import {
|
||||
ExtensionStatus,
|
||||
ExtensionDependencies,
|
||||
ExtensionHostMessage,
|
||||
ExtensionHostCommand,
|
||||
ExtensionRunOptions,
|
||||
type ExtensionDependencies,
|
||||
type ExtensionHostMessage,
|
||||
type ExtensionHostCommand,
|
||||
type ExtensionRunOptions,
|
||||
} from './types';
|
||||
|
||||
export const blankTemplate = (slug: string) => `// ==Extension==
|
||||
|
@ -30,16 +30,14 @@ export class Extension extends EventTarget {
|
|||
) {
|
||||
super();
|
||||
|
||||
this.worker = new Worker(
|
||||
new URL('./workers/extensionHost.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
this.worker = new Worker(new URL('./workers/extensionHost.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
this.worker.onerror = (ev) => {
|
||||
this.status = ExtensionStatus.Error;
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: ev }));
|
||||
};
|
||||
this.worker.onmessage = (ev: MessageEvent<ExtensionHostMessage>) =>
|
||||
this.messageReceived(ev);
|
||||
this.worker.onmessage = (ev: MessageEvent<ExtensionHostMessage>) => this.messageReceived(ev);
|
||||
|
||||
// Initialize ext host
|
||||
this.send({
|
||||
|
@ -86,10 +84,7 @@ export class Extension extends EventTarget {
|
|||
}
|
||||
|
||||
public get running() {
|
||||
return (
|
||||
this.status === ExtensionStatus.Running ||
|
||||
this.status === ExtensionStatus.Finished
|
||||
);
|
||||
return this.status === ExtensionStatus.Running || this.status === ExtensionStatus.Finished;
|
||||
}
|
||||
|
||||
start() {
|
||||
|
@ -105,9 +100,7 @@ export class Extension extends EventTarget {
|
|||
case ExtensionStatus.Finished:
|
||||
throw new Error('extension is already running');
|
||||
case ExtensionStatus.Terminated:
|
||||
throw new Error(
|
||||
'extension has been terminated, did you forget to trash this instance?',
|
||||
);
|
||||
throw new Error('extension has been terminated, did you forget to trash this instance?');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,9 +13,7 @@ interface ExtensionMetadata {
|
|||
apiversion: string;
|
||||
}
|
||||
|
||||
export function parseExtensionMetadata(
|
||||
source: string,
|
||||
): ExtensionMetadata | null {
|
||||
export function parseExtensionMetadata(source: string): ExtensionMetadata | null {
|
||||
// Find metadata block
|
||||
const start = source.indexOf('// ==Extension==');
|
||||
const end = source.indexOf('// ==/Extension==', start);
|
||||
|
|
|
@ -9,7 +9,7 @@ export interface ExtensionOptions {
|
|||
enabled: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
// biome-ignore lint/suspicious/noEmptyInterface: Not used for now
|
||||
export interface ExtensionRunOptions {}
|
||||
|
||||
export enum ExtensionStatus {
|
||||
|
|
|
@ -1,18 +1,11 @@
|
|||
import Kilovolt from '@strimertul/kilovolt-client';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
ExtensionHostCommand,
|
||||
ExtensionHostMessage,
|
||||
ExtensionStatus,
|
||||
} from '../types';
|
||||
import { SourceMapMappings, parseSourceMap } from '../sourceMap';
|
||||
import { type ExtensionHostCommand, type ExtensionHostMessage, ExtensionStatus } from '../types';
|
||||
import { type SourceMapMappings, parseSourceMap } from '../sourceMap';
|
||||
|
||||
const sendMessage = (
|
||||
message: ExtensionHostMessage,
|
||||
transfer?: Transferable[],
|
||||
) => postMessage(message, transfer);
|
||||
const sendMessage = (message: ExtensionHostMessage, transfer?: Transferable[]) =>
|
||||
postMessage(message, transfer);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, no-empty-function
|
||||
async function ExtensionFunction(_kv: Kilovolt) {}
|
||||
|
||||
let extFn: typeof ExtensionFunction = null;
|
||||
|
@ -28,9 +21,9 @@ function setStatus(status: ExtensionStatus) {
|
|||
});
|
||||
}
|
||||
|
||||
function log(level: string, sourceMap: SourceMapMappings) {
|
||||
function log(level: string, _sourceMap: SourceMapMappings) {
|
||||
// eslint-disable-next-line func-names
|
||||
return function (...args: { toString(): string }[]) {
|
||||
return (...args: { toString(): string }[]) => {
|
||||
const message = args.join(' ');
|
||||
void kv.putJSON('strimertul/@log', {
|
||||
level,
|
||||
|
@ -61,7 +54,7 @@ function start() {
|
|||
setStatus(ExtensionStatus.Running);
|
||||
}
|
||||
|
||||
onmessage = async (ev: MessageEvent<ExtensionHostCommand>) => {
|
||||
addEventListener('message', async (ev: MessageEvent<ExtensionHostCommand>) => {
|
||||
const cmd = ev.data;
|
||||
switch (cmd.kind) {
|
||||
case 'arguments': {
|
||||
|
@ -107,4 +100,4 @@ onmessage = async (ev: MessageEvent<ExtensionHostCommand>) => {
|
|||
start();
|
||||
break;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends":"../../../../tsconfig.json",
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2019", "WebWorker"],
|
||||
"lib": ["es2019", "WebWorker"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,9 @@
|
|||
import {
|
||||
ActionCreatorWithOptionalPayload,
|
||||
AsyncThunk,
|
||||
Draft,
|
||||
} from '@reduxjs/toolkit';
|
||||
import type { ActionCreatorWithOptionalPayload, AsyncThunk, Draft } from '@reduxjs/toolkit';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
KilovoltMessage,
|
||||
SubscriptionHandler,
|
||||
} from '@strimertul/kilovolt-client';
|
||||
import { RootState, useAppDispatch } from '~/store';
|
||||
import type { KilovoltMessage, SubscriptionHandler } from '@strimertul/kilovolt-client';
|
||||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import apiReducer, { getUserPoints } from '~/store/api/reducer';
|
||||
import {
|
||||
import type {
|
||||
APIState,
|
||||
LoyaltyPointsEntry,
|
||||
LoyaltyStorage,
|
||||
|
@ -23,8 +15,10 @@ interface LoadStatus {
|
|||
save: RequestStatus;
|
||||
}
|
||||
|
||||
export const useKilovoltClient = () => useAppSelector((state) => state.api.client);
|
||||
|
||||
export function useLiveKeyString(key: string) {
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const client = useKilovoltClient();
|
||||
const [data, setData] = useState<string>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -34,7 +28,7 @@ export function useLiveKeyString(key: string) {
|
|||
return () => {
|
||||
void client.unsubscribeKey(key, subscriber);
|
||||
};
|
||||
}, []);
|
||||
}, [key]);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
@ -53,26 +47,16 @@ export function useModule<T>({
|
|||
}: {
|
||||
key: string;
|
||||
selector: (state: Draft<APIState>) => T;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
getter: AsyncThunk<T, void, {}>;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
setter: AsyncThunk<KilovoltMessage, T, {}>;
|
||||
getter: AsyncThunk<T, void, unknown>;
|
||||
setter: AsyncThunk<KilovoltMessage, T, unknown>;
|
||||
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
|
||||
}): [
|
||||
T,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
AsyncThunk<KilovoltMessage, T, {}>,
|
||||
LoadStatus,
|
||||
] {
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const data = useSelector((state: RootState) => selector(state.api));
|
||||
const loadStatus = useSelector(
|
||||
(state: RootState) => state.api.requestStatus[`load-${key}`],
|
||||
);
|
||||
const saveStatus = useSelector(
|
||||
(state: RootState) => state.api.requestStatus[`save-${key}`],
|
||||
);
|
||||
}): [T, AsyncThunk<KilovoltMessage, T, unknown>, LoadStatus] {
|
||||
const client = useKilovoltClient();
|
||||
const data = useAppSelector((state) => selector(state.api));
|
||||
const loadStatus = useAppSelector((state) => state.api.requestStatus[`load-${key}`]);
|
||||
const saveStatus = useAppSelector((state) => state.api.requestStatus[`save-${key}`]);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(getter());
|
||||
const subscriber: SubscriptionHandler = (newValue) => {
|
||||
|
@ -81,11 +65,10 @@ export function useModule<T>({
|
|||
void client.subscribeKey(key, subscriber);
|
||||
return () => {
|
||||
void client.unsubscribeKey(key, subscriber);
|
||||
dispatch(
|
||||
apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]),
|
||||
);
|
||||
dispatch(apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]));
|
||||
};
|
||||
}, []);
|
||||
}, [key, getter, asyncSetter]);
|
||||
|
||||
return [
|
||||
data,
|
||||
setter,
|
||||
|
@ -96,14 +79,22 @@ export function useModule<T>({
|
|||
];
|
||||
}
|
||||
|
||||
export function useStatus(
|
||||
status: RequestStatus | null,
|
||||
interval = 5000,
|
||||
): RequestStatus | null {
|
||||
const interval = 5000;
|
||||
/**
|
||||
* Sets a timed reactive status based on a RequestStatus.
|
||||
* This allows UI elements to be updated only for some time based on the status of a request.
|
||||
* Once the status changes, the status on the output will be set for 5 seconds then reset to null.
|
||||
* @param status Status to monitor
|
||||
* @returns Reactive status
|
||||
*/
|
||||
export function useTimedStatus(status: RequestStatus | null): RequestStatus | null {
|
||||
const [localStatus, setlocalStatus] = useState(status);
|
||||
const maxTime = Date.now() - interval;
|
||||
const [maxTime, setMaxTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const remaining = status ? status.updated.getTime() - maxTime : null;
|
||||
const timeout = Date.now() - interval;
|
||||
setMaxTime(timeout);
|
||||
const remaining = status ? status.updated.getTime() - timeout : null;
|
||||
if (remaining) {
|
||||
setTimeout(() => {
|
||||
setlocalStatus(null);
|
||||
|
@ -117,28 +108,28 @@ export function useStatus(
|
|||
|
||||
export function useUserPoints(): LoyaltyStorage {
|
||||
const prefix = 'loyalty/points/';
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const data = useSelector((state: RootState) => state.api.loyalty.users);
|
||||
const client = useKilovoltClient();
|
||||
const data = useAppSelector((state) => state.api.loyalty.users);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(getUserPoints());
|
||||
const subscriber: SubscriptionHandler = (newValue, key) => {
|
||||
const user = key.substring(prefix.length);
|
||||
const entry = JSON.parse(newValue) as LoyaltyPointsEntry;
|
||||
void dispatch(
|
||||
apiReducer.actions.loyaltyUserPointsChanged({ user, entry }),
|
||||
);
|
||||
void dispatch(apiReducer.actions.loyaltyUserPointsChanged({ user, entry }));
|
||||
};
|
||||
void client.subscribePrefix(prefix, subscriber);
|
||||
return () => {
|
||||
void client.unsubscribePrefix(prefix, subscriber);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default {
|
||||
useModule,
|
||||
useStatus,
|
||||
useStatus: useTimedStatus,
|
||||
useUserPoints,
|
||||
};
|
||||
|
|
|
@ -43,10 +43,7 @@ export async function twitchAuth(
|
|||
* @param clientSecret App Client secret
|
||||
* @throws Credentials are not valid or request failed
|
||||
*/
|
||||
export async function checkTwitchKeys(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<void> {
|
||||
export async function checkTwitchKeys(clientId: string, clientSecret: string): Promise<void> {
|
||||
const creds = await twitchAuth(clientId, clientSecret);
|
||||
const req = await fetch('https://api.twitch.tv/helix/streams?first=1', {
|
||||
headers: {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceKey } from 'i18next';
|
||||
import type { ResourceKey } from 'i18next';
|
||||
import en from './en/translation.json';
|
||||
import it from './it/translation.json';
|
||||
|
||||
|
@ -6,10 +6,7 @@ function countKeys(res: ResourceKey): number {
|
|||
if (typeof res === 'string') {
|
||||
return 1;
|
||||
}
|
||||
return Object.values(res).reduce<number>(
|
||||
(acc: number, k: ResourceKey) => acc + countKeys(k),
|
||||
0,
|
||||
);
|
||||
return Object.values(res).reduce<number>((acc: number, k: ResourceKey) => acc + countKeys(k), 0);
|
||||
}
|
||||
|
||||
interface LanguageMeta {
|
||||
|
|
|
@ -1,36 +1,35 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import {
|
||||
AsyncThunk,
|
||||
CaseReducer,
|
||||
type AsyncThunk,
|
||||
type CaseReducer,
|
||||
createAction,
|
||||
createAsyncThunk,
|
||||
createSlice,
|
||||
Dispatch,
|
||||
PayloadAction,
|
||||
UnknownAction,
|
||||
type Dispatch,
|
||||
type PayloadAction,
|
||||
type UnknownAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
import KilovoltWS, { KilovoltMessage } from '@strimertul/kilovolt-client';
|
||||
import KilovoltWS, { type KilovoltMessage } from '@strimertul/kilovolt-client';
|
||||
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
||||
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
|
||||
import { delay } from '~/lib/time';
|
||||
import {
|
||||
APIState,
|
||||
type APIState,
|
||||
ConnectionStatus,
|
||||
HTTPConfig,
|
||||
LoyaltyPointsEntry,
|
||||
LoyaltyRedeem,
|
||||
LoyaltyStorage,
|
||||
TwitchChatConfig,
|
||||
TwitchConfig,
|
||||
TwitchChatCustomCommands,
|
||||
TwitchChatTimersConfig,
|
||||
TwitchChatAlertsConfig,
|
||||
LoyaltyConfig,
|
||||
LoyaltyReward,
|
||||
LoyaltyGoal,
|
||||
UISettings,
|
||||
type HTTPConfig,
|
||||
type LoyaltyPointsEntry,
|
||||
type LoyaltyRedeem,
|
||||
type LoyaltyStorage,
|
||||
type TwitchChatConfig,
|
||||
type TwitchConfig,
|
||||
type TwitchChatCustomCommands,
|
||||
type TwitchChatTimersConfig,
|
||||
type TwitchChatAlertsConfig,
|
||||
type LoyaltyConfig,
|
||||
type LoyaltyReward,
|
||||
type LoyaltyGoal,
|
||||
type UISettings,
|
||||
} from './types';
|
||||
import { ThunkConfig } from '..';
|
||||
import type { ThunkConfig } from '..';
|
||||
|
||||
type ThunkAPIState = { api: APIState };
|
||||
|
||||
|
@ -80,11 +79,10 @@ function makeModule<T>(
|
|||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
let setupClientReconnect: AsyncThunk<void, KilovoltWS, {}>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
let kvErrorReceived: AsyncThunk<void, kvError, {}>;
|
||||
// biome-ignore lint/style/useConst: Assigned later
|
||||
let setupClientReconnect: AsyncThunk<void, KilovoltWS, unknown>;
|
||||
// biome-ignore lint/style/useConst: Assigned later
|
||||
let kvErrorReceived: AsyncThunk<void, kvError, unknown>;
|
||||
|
||||
// Storage
|
||||
const loyaltyPointsPrefix = 'loyalty/points/';
|
||||
|
@ -121,21 +119,20 @@ export const createWSClient = createAsyncThunk(
|
|||
},
|
||||
);
|
||||
|
||||
export const getUserPoints = createAsyncThunk<
|
||||
LoyaltyStorage,
|
||||
void,
|
||||
ThunkConfig
|
||||
>('api/getUserPoints', async (_, { getState }) => {
|
||||
const { api } = getState();
|
||||
const keys = await api.client.getKeysByPrefix(loyaltyPointsPrefix);
|
||||
const userpoints: LoyaltyStorage = {};
|
||||
Object.entries(keys).forEach(([k, v]) => {
|
||||
userpoints[k.substring(loyaltyPointsPrefix.length)] = JSON.parse(
|
||||
v,
|
||||
) as LoyaltyPointsEntry;
|
||||
});
|
||||
return userpoints;
|
||||
});
|
||||
export const getUserPoints = createAsyncThunk<LoyaltyStorage, void, ThunkConfig>(
|
||||
'api/getUserPoints',
|
||||
async (_, { getState }) => {
|
||||
const { api } = getState();
|
||||
const keys = await api.client.getKeysByPrefix(loyaltyPointsPrefix);
|
||||
const userpoints: LoyaltyStorage = {};
|
||||
for (const key in keys) {
|
||||
userpoints[key.substring(loyaltyPointsPrefix.length)] = JSON.parse(
|
||||
keys[key],
|
||||
) as LoyaltyPointsEntry;
|
||||
}
|
||||
return userpoints;
|
||||
},
|
||||
);
|
||||
|
||||
export const setUserPoints = createAsyncThunk<
|
||||
KilovoltMessage,
|
||||
|
@ -230,29 +227,24 @@ export const modules = {
|
|||
),
|
||||
};
|
||||
|
||||
export const createRedeem = createAsyncThunk<
|
||||
KilovoltMessage,
|
||||
LoyaltyRedeem,
|
||||
ThunkConfig
|
||||
>('api/createRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
|
||||
const { api } = getState();
|
||||
return api.client.putJSON(loyaltyCreateRedeemKey, redeem);
|
||||
});
|
||||
export const createRedeem = createAsyncThunk<KilovoltMessage, LoyaltyRedeem, ThunkConfig>(
|
||||
'api/createRedeem',
|
||||
async (redeem: LoyaltyRedeem, { getState }) => {
|
||||
const { api } = getState();
|
||||
return api.client.putJSON(loyaltyCreateRedeemKey, redeem);
|
||||
},
|
||||
);
|
||||
|
||||
export const removeRedeem = createAsyncThunk<
|
||||
KilovoltMessage,
|
||||
LoyaltyRedeem,
|
||||
ThunkConfig
|
||||
>('api/removeRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
|
||||
const { api } = getState();
|
||||
return api.client.putJSON(loyaltyRemoveRedeemKey, redeem);
|
||||
});
|
||||
export const removeRedeem = createAsyncThunk<KilovoltMessage, LoyaltyRedeem, ThunkConfig>(
|
||||
'api/removeRedeem',
|
||||
async (redeem: LoyaltyRedeem, { getState }) => {
|
||||
const { api } = getState();
|
||||
return api.client.putJSON(loyaltyRemoveRedeemKey, redeem);
|
||||
},
|
||||
);
|
||||
|
||||
const moduleChangeReducers = Object.fromEntries(
|
||||
Object.entries(modules).map(([key, mod]) => [
|
||||
`${key}Changed`,
|
||||
mod.stateSetter,
|
||||
]),
|
||||
Object.entries(modules).map(([key, mod]) => [`${key}Changed`, mod.stateSetter]),
|
||||
) as Record<
|
||||
`${keyof typeof modules}Changed`,
|
||||
(state: APIState, action: PayloadAction<unknown>) => never
|
||||
|
@ -292,10 +284,7 @@ const apiReducer = createSlice({
|
|||
initialLoadCompleted(state) {
|
||||
state.initialLoadComplete = true;
|
||||
},
|
||||
connectionStatusChanged(
|
||||
state,
|
||||
{ payload }: PayloadAction<ConnectionStatus>,
|
||||
) {
|
||||
connectionStatusChanged(state, { payload }: PayloadAction<ConnectionStatus>) {
|
||||
state.connectionStatus = payload;
|
||||
},
|
||||
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
|
||||
|
@ -303,16 +292,14 @@ const apiReducer = createSlice({
|
|||
},
|
||||
loyaltyUserPointsChanged(
|
||||
state,
|
||||
{
|
||||
payload: { user, entry },
|
||||
}: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
|
||||
{ payload: { user, entry } }: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
|
||||
) {
|
||||
state.loyalty.users[user] = entry;
|
||||
},
|
||||
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
|
||||
payload.forEach((key) => {
|
||||
for (const key of payload) {
|
||||
delete state.requestStatus[key];
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
|
@ -323,7 +310,7 @@ const apiReducer = createSlice({
|
|||
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
|
||||
state.loyalty.users = payload;
|
||||
});
|
||||
Object.values(modules).forEach((mod) => {
|
||||
for (const mod of Object.values(modules)) {
|
||||
builder.addCase(mod.getter.pending, (state) => {
|
||||
state.requestStatus[`load-${mod.key}`] = {
|
||||
type: 'pending',
|
||||
|
@ -364,7 +351,7 @@ const apiReducer = createSlice({
|
|||
};
|
||||
});
|
||||
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -376,50 +363,35 @@ setupClientReconnect = createAsyncThunk(
|
|||
console.info('Attempting reconnection');
|
||||
client.reconnect();
|
||||
}, 5000);
|
||||
dispatch(
|
||||
apiReducer.actions.connectionStatusChanged(
|
||||
ConnectionStatus.NotConnected,
|
||||
),
|
||||
);
|
||||
dispatch(apiReducer.actions.connectionStatusChanged(ConnectionStatus.NotConnected));
|
||||
});
|
||||
client.on('open', () => {
|
||||
dispatch(
|
||||
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
|
||||
);
|
||||
dispatch(apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
kvErrorReceived = createAsyncThunk(
|
||||
'api/kvErrorReceived',
|
||||
(error: kvError, { dispatch }) => {
|
||||
switch (error.error) {
|
||||
case 'authentication required':
|
||||
case 'authentication failed':
|
||||
dispatch(
|
||||
apiReducer.actions.connectionStatusChanged(
|
||||
ConnectionStatus.AuthenticationNeeded,
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Unsupported error
|
||||
dispatch(apiReducer.actions.kvErrorReceived(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
kvErrorReceived = createAsyncThunk('api/kvErrorReceived', (error: kvError, { dispatch }) => {
|
||||
switch (error.error) {
|
||||
case 'authentication required':
|
||||
case 'authentication failed':
|
||||
dispatch(apiReducer.actions.connectionStatusChanged(ConnectionStatus.AuthenticationNeeded));
|
||||
break;
|
||||
default:
|
||||
// Unsupported error
|
||||
dispatch(apiReducer.actions.kvErrorReceived(error));
|
||||
}
|
||||
});
|
||||
|
||||
export const useAuthBypass = createAsyncThunk<void, void, ThunkConfig>(
|
||||
'api/authBypass',
|
||||
async (_: void, { getState, dispatch }) => {
|
||||
async (_: never, { getState, dispatch }) => {
|
||||
const { api } = getState();
|
||||
const response = await api.client.send({ command: '_uid' });
|
||||
if ('ok' in response && response.ok && 'data' in response) {
|
||||
const uid = response.data as string;
|
||||
await AuthenticateKVClient(uid.toString());
|
||||
dispatch(
|
||||
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
|
||||
);
|
||||
dispatch(apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable camelcase */
|
||||
|
||||
import KilovoltWS from '@strimertul/kilovolt-client';
|
||||
import type KilovoltWS from '@strimertul/kilovolt-client';
|
||||
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
||||
|
||||
export interface HTTPConfig {
|
||||
|
@ -20,13 +20,7 @@ export interface TwitchChatConfig {
|
|||
command_cooldown: number;
|
||||
}
|
||||
|
||||
export const accessLevels = [
|
||||
'everyone',
|
||||
'subscribers',
|
||||
'vip',
|
||||
'moderators',
|
||||
'streamer',
|
||||
] as const;
|
||||
export const accessLevels = ['everyone', 'subscribers', 'vip', 'moderators', 'streamer'] as const;
|
||||
|
||||
export type AccessLevelType = (typeof accessLevels)[number];
|
||||
|
||||
|
@ -150,9 +144,9 @@ export interface UISettings {
|
|||
}
|
||||
|
||||
export enum ConnectionStatus {
|
||||
NotConnected,
|
||||
AuthenticationNeeded,
|
||||
Connected,
|
||||
NotConnected = 'not-connected',
|
||||
AuthenticationNeeded = 'auth-needed',
|
||||
Connected = 'connected',
|
||||
}
|
||||
|
||||
export type RequestStatus =
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { Extension } from '~/lib/extensions/extension';
|
||||
import {
|
||||
ExtensionDependencies,
|
||||
ExtensionOptions,
|
||||
ExtensionRunOptions,
|
||||
type ExtensionDependencies,
|
||||
type ExtensionOptions,
|
||||
type ExtensionRunOptions,
|
||||
ExtensionStatus,
|
||||
} from '~/lib/extensions/types';
|
||||
import { ThunkConfig } from '..';
|
||||
import { HTTPConfig } from '../api/types';
|
||||
import type { ThunkConfig } from '..';
|
||||
import type { HTTPConfig } from '../api/types';
|
||||
|
||||
interface ExtensionsState {
|
||||
ready: boolean;
|
||||
|
@ -121,11 +120,7 @@ export const createExtensionInstance = createAsyncThunk<
|
|||
},
|
||||
ThunkConfig
|
||||
>('extensions/new-instance', (payload, { dispatch }) => {
|
||||
const ext = new Extension(
|
||||
payload.entry,
|
||||
payload.dependencies,
|
||||
payload.runOptions,
|
||||
);
|
||||
const ext = new Extension(payload.entry, payload.dependencies, payload.runOptions);
|
||||
ext.addEventListener('statusChanged', (ev: CustomEvent<ExtensionStatus>) => {
|
||||
dispatch(
|
||||
extensionsReducer.actions.extensionStatusChanged({
|
||||
|
@ -138,28 +133,27 @@ export const createExtensionInstance = createAsyncThunk<
|
|||
dispatch(extensionsReducer.actions.extensionInstanceAdded(ext));
|
||||
});
|
||||
|
||||
export const refreshExtensionInstance = createAsyncThunk<
|
||||
void,
|
||||
ExtensionEntry,
|
||||
ThunkConfig
|
||||
>('extensions/refresh-instance', async (payload, { dispatch, getState }) => {
|
||||
const { extensions } = getState();
|
||||
if (payload.options.enabled) {
|
||||
await dispatch(
|
||||
createExtensionInstance({
|
||||
entry: payload,
|
||||
dependencies: extensions.dependencies,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// If running, terminate running instance
|
||||
if (payload.name in extensions.running) {
|
||||
extensions.running[payload.name]?.dispose();
|
||||
}
|
||||
export const refreshExtensionInstance = createAsyncThunk<void, ExtensionEntry, ThunkConfig>(
|
||||
'extensions/refresh-instance',
|
||||
async (payload, { dispatch, getState }) => {
|
||||
const { extensions } = getState();
|
||||
if (payload.options.enabled) {
|
||||
await dispatch(
|
||||
createExtensionInstance({
|
||||
entry: payload,
|
||||
dependencies: extensions.dependencies,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// If running, terminate running instance
|
||||
if (payload.name in extensions.running) {
|
||||
extensions.running[payload.name]?.dispose();
|
||||
}
|
||||
|
||||
dispatch(extensionsReducer.actions.extensionAdded(payload));
|
||||
}
|
||||
});
|
||||
dispatch(extensionsReducer.actions.extensionAdded(payload));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const initializeExtensions = createAsyncThunk<void, void, ThunkConfig>(
|
||||
'extensions/initialize',
|
||||
|
@ -247,20 +241,18 @@ export const stopExtension = createAsyncThunk<void, string, ThunkConfig>(
|
|||
},
|
||||
);
|
||||
|
||||
export const saveExtension = createAsyncThunk<
|
||||
void,
|
||||
ExtensionEntry,
|
||||
ThunkConfig
|
||||
>('extensions/save', async (entry, { getState }) => {
|
||||
// Get kv client
|
||||
const { api } = getState();
|
||||
await api.client.putJSON(extensionPrefix + entry.name, entry);
|
||||
});
|
||||
export const saveExtension = createAsyncThunk<void, ExtensionEntry, ThunkConfig>(
|
||||
'extensions/save',
|
||||
async (entry, { getState }) => {
|
||||
// Get kv client
|
||||
const { api } = getState();
|
||||
await api.client.putJSON(extensionPrefix + entry.name, entry);
|
||||
},
|
||||
);
|
||||
|
||||
export const isUnsaved = (ext: ExtensionsState) =>
|
||||
ext.editorCurrentFile in ext.unsaved &&
|
||||
ext.unsaved[ext.editorCurrentFile] !==
|
||||
ext.installed[ext.editorCurrentFile]?.source;
|
||||
ext.unsaved[ext.editorCurrentFile] !== ext.installed[ext.editorCurrentFile]?.source;
|
||||
|
||||
export const currentFile = (ext: ExtensionsState) =>
|
||||
isUnsaved(ext)
|
||||
|
@ -296,31 +288,30 @@ export const removeExtension = createAsyncThunk<void, string, ThunkConfig>(
|
|||
},
|
||||
);
|
||||
|
||||
export const renameExtension = createAsyncThunk<
|
||||
void,
|
||||
{ from: string; to: string },
|
||||
ThunkConfig
|
||||
>('extensions/rename', async (payload, { getState, dispatch }) => {
|
||||
const { extensions } = getState();
|
||||
export const renameExtension = createAsyncThunk<void, { from: string; to: string }, ThunkConfig>(
|
||||
'extensions/rename',
|
||||
async (payload, { getState, dispatch }) => {
|
||||
const { extensions } = getState();
|
||||
|
||||
// Save old entries
|
||||
const unsaved = extensions.unsaved[payload.from];
|
||||
const entry = extensions.installed[payload.from];
|
||||
// Save old entries
|
||||
const unsaved = extensions.unsaved[payload.from];
|
||||
const entry = extensions.installed[payload.from];
|
||||
|
||||
// Remove and re-add under new name
|
||||
await dispatch(removeExtension(payload.from));
|
||||
await dispatch(
|
||||
saveExtension({
|
||||
...entry,
|
||||
name: payload.to,
|
||||
}),
|
||||
);
|
||||
// Remove and re-add under new name
|
||||
await dispatch(removeExtension(payload.from));
|
||||
await dispatch(
|
||||
saveExtension({
|
||||
...entry,
|
||||
name: payload.to,
|
||||
}),
|
||||
);
|
||||
|
||||
// Set unsaved and current file
|
||||
dispatch(extensionsReducer.actions.editorSelectedFile(payload.to));
|
||||
if (unsaved) {
|
||||
dispatch(extensionsReducer.actions.extensionSourceChanged(unsaved));
|
||||
}
|
||||
});
|
||||
// Set unsaved and current file
|
||||
dispatch(extensionsReducer.actions.editorSelectedFile(payload.to));
|
||||
if (unsaved) {
|
||||
dispatch(extensionsReducer.actions.extensionSourceChanged(unsaved));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default extensionsReducer;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { EqualityFn, useDispatch, useSelector } from 'react-redux';
|
||||
import { type EqualityFn, useDispatch, useSelector } from 'react-redux';
|
||||
import { thunk } from 'redux-thunk';
|
||||
|
||||
import apiReducer from './api/reducer';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { main } from '@wailsapp/go/models';
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { main } from '@wailsapp/go/models';
|
||||
|
||||
export interface ProcessedLogEntry {
|
||||
id: string;
|
||||
|
@ -10,13 +9,7 @@ export interface ProcessedLogEntry {
|
|||
data: object;
|
||||
}
|
||||
|
||||
export function processEntry({
|
||||
id,
|
||||
time,
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
}: main.LogEntry): ProcessedLogEntry {
|
||||
export function processEntry({ id, time, level, message, data }: main.LogEntry): ProcessedLogEntry {
|
||||
return {
|
||||
id,
|
||||
time: new Date(time),
|
||||
|
@ -44,9 +37,7 @@ const loggingReducer = createSlice({
|
|||
const logKeys = payload.map(keyfn);
|
||||
|
||||
// Clean up duplicates before setting to state
|
||||
const uniqueLogs = payload.filter(
|
||||
(ev, pos) => logKeys.indexOf(keyfn(ev)) === pos,
|
||||
);
|
||||
const uniqueLogs = payload.filter((ev, pos) => logKeys.indexOf(keyfn(ev)) === pos);
|
||||
|
||||
state.messages = uniqueLogs
|
||||
.map(processEntry)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { GetAppVersion } from '@wailsapp/go/main/App';
|
||||
import { main } from '@wailsapp/go/models';
|
||||
import type { main } from '@wailsapp/go/models';
|
||||
|
||||
interface ServerState {
|
||||
version: main.VersionInfo;
|
||||
|
@ -23,7 +23,7 @@ const serverReducer = createSlice({
|
|||
|
||||
export const initializeServerInfo = createAsyncThunk(
|
||||
'server/init-info',
|
||||
async (_: void, { dispatch }) => {
|
||||
async (_: never, { dispatch }) => {
|
||||
dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion()));
|
||||
},
|
||||
);
|
||||
|
|
|
@ -14,12 +14,8 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
GetKilovoltBind,
|
||||
GetLastLogs,
|
||||
IsServerReady,
|
||||
} from '@wailsapp/go/main/App';
|
||||
import { main } from '@wailsapp/go/models';
|
||||
import { GetKilovoltBind, GetLastLogs, IsServerReady } from '@wailsapp/go/main/App';
|
||||
import type { main } from '@wailsapp/go/models';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import { createWSClient, useAuthBypass } from '~/store/api/reducer';
|
||||
|
@ -29,7 +25,7 @@ import { initializeExtensions } from '~/store/extensions/reducer';
|
|||
import { initializeServerInfo } from '~/store/server/reducer';
|
||||
|
||||
import LogViewer from './components/LogViewer';
|
||||
import Sidebar, { RouteSection } from './components/Sidebar';
|
||||
import Sidebar, { type RouteSection } from './components/Sidebar';
|
||||
import Scrollbar from './components/utils/Scrollbar';
|
||||
import TwitchChatCommandsPage from './pages/twitch/ChatCommands';
|
||||
import TwitchChatTimersPage from './pages/twitch/ChatTimers';
|
||||
|
@ -48,6 +44,7 @@ import ExtensionsPage from './pages/system/Extensions';
|
|||
import { getTheme, styled } from './theme';
|
||||
import Loading from './components/Loading';
|
||||
import InteractiveAuthDialog from './components/InteractiveAuthDialog';
|
||||
import { useKilovoltClient } from '~/lib/react';
|
||||
|
||||
const sections: RouteSection[] = [
|
||||
{
|
||||
|
@ -155,7 +152,7 @@ const PageWrapper = styled('div', {
|
|||
|
||||
export default function App(): JSX.Element {
|
||||
const [ready, setReady] = useState(false);
|
||||
const client = useAppSelector((state) => state.api.client);
|
||||
const client = useKilovoltClient();
|
||||
const uiConfig = useAppSelector((state) => state.api.uiConfig);
|
||||
const connected = useAppSelector((state) => state.api.connectionStatus);
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -163,16 +160,8 @@ export default function App(): JSX.Element {
|
|||
const navigate = useNavigate();
|
||||
const [t, i18n] = useTranslation();
|
||||
|
||||
const connectToKV = async () => {
|
||||
const address = await GetKilovoltBind();
|
||||
await dispatch(
|
||||
createWSClient({
|
||||
address: `ws://${address}/ws`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Fill application info
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: False positive
|
||||
useEffect(() => {
|
||||
void dispatch(initializeServerInfo());
|
||||
// Load language from local storage until db is ready
|
||||
|
@ -208,6 +197,15 @@ export default function App(): JSX.Element {
|
|||
|
||||
// Connect to kilovolt as soon as it's available
|
||||
useEffect(() => {
|
||||
const connectToKV = async () => {
|
||||
const address = await GetKilovoltBind();
|
||||
await dispatch(
|
||||
createWSClient({
|
||||
address: `ws://${address}/ws`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
@ -227,6 +225,7 @@ export default function App(): JSX.Element {
|
|||
}, [ready, connected]);
|
||||
|
||||
// Sync UI changes on key change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: False positive
|
||||
useEffect(() => {
|
||||
if (uiConfig?.language) {
|
||||
void i18n.changeLanguage(uiConfig.language ?? 'en');
|
||||
|
@ -238,19 +237,15 @@ export default function App(): JSX.Element {
|
|||
if (!uiConfig?.onboardingDone) {
|
||||
navigate('/setup');
|
||||
}
|
||||
}, [ready, uiConfig]);
|
||||
}, [uiConfig]);
|
||||
|
||||
const theme = getTheme(
|
||||
uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark',
|
||||
);
|
||||
const theme = getTheme(uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark');
|
||||
|
||||
if (
|
||||
connected === ConnectionStatus.NotConnected ||
|
||||
connected === ConnectionStatus.AuthenticationNeeded
|
||||
) {
|
||||
return (
|
||||
<Loading theme={theme} size="fullscreen" message={t('special.loading')} />
|
||||
);
|
||||
return <Loading theme={theme} size="fullscreen" message={t('special.loading')} />;
|
||||
}
|
||||
|
||||
const showSidebar = location.pathname !== '/setup';
|
||||
|
@ -260,11 +255,7 @@ export default function App(): JSX.Element {
|
|||
<InteractiveAuthDialog />
|
||||
<LogViewer />
|
||||
{showSidebar ? <Sidebar sections={sections} /> : null}
|
||||
<Scrollbar
|
||||
vertical={true}
|
||||
root={{ flex: 1 }}
|
||||
viewport={{ height: '100vh', flex: '1' }}
|
||||
>
|
||||
<Scrollbar vertical={true} root={{ flex: 1 }} viewport={{ height: '100vh', flex: '1' }}>
|
||||
<PageContent>
|
||||
<PageWrapper role="main">
|
||||
<Routes>
|
||||
|
@ -276,14 +267,8 @@ export default function App(): JSX.Element {
|
|||
<Route path="/ui-config" element={<UISettingsPage />} />
|
||||
<Route path="/extensions" element={<ExtensionsPage />} />
|
||||
<Route path="/twitch/settings" element={<TwitchSettingsPage />} />
|
||||
<Route
|
||||
path="/twitch/chat/commands"
|
||||
element={<TwitchChatCommandsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/twitch/chat/timers"
|
||||
element={<TwitchChatTimersPage />}
|
||||
/>
|
||||
<Route path="/twitch/chat/commands" element={<TwitchChatCommandsPage />} />
|
||||
<Route path="/twitch/chat/timers" element={<TwitchChatTimersPage />} />
|
||||
<Route path="/twitch/chat/alerts" element={<ChatAlertsPage />} />
|
||||
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
|
||||
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
GetBackups,
|
||||
GetLastLogs,
|
||||
RestoreBackup,
|
||||
SendCrashReport,
|
||||
} from '@wailsapp/go/main/App';
|
||||
import { GetBackups, GetLastLogs, RestoreBackup, SendCrashReport } from '@wailsapp/go/main/App';
|
||||
import type { main } from '@wailsapp/go/models';
|
||||
import { EventsOff, EventsOn } from '@wailsapp/runtime';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { languages } from '~/locale/languages';
|
||||
import { ProcessedLogEntry, processEntry } from '~/store/logging/reducer';
|
||||
import { type ProcessedLogEntry, processEntry } from '~/store/logging/reducer';
|
||||
import DialogContent from '~/ui/components/DialogContent';
|
||||
import { LogItem } from '~/ui/components/LogViewer';
|
||||
import Scrollbar from '~/ui/components/utils/Scrollbar';
|
||||
|
@ -154,9 +149,7 @@ function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) {
|
|||
const { t } = useTranslation();
|
||||
const [backups, setBackups] = useState<main.BackupInfo[]>([]);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [restored, setRestored] = useState<'idle' | 'in-progress' | 'done'>(
|
||||
'idle',
|
||||
);
|
||||
const [restored, setRestored] = useState<'idle' | 'in-progress' | 'done'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
void GetBackups().then((backupList) => {
|
||||
|
@ -234,14 +227,9 @@ function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
title={t('pages.crash.recovery.title')}
|
||||
closeButton={true}
|
||||
>
|
||||
<DialogContent title={t('pages.crash.recovery.title')} closeButton={true}>
|
||||
<TextBlock>{t('pages.crash.recovery.text-head')}</TextBlock>
|
||||
<SectionHeader>
|
||||
{t('pages.crash.recovery.restore-head')}
|
||||
</SectionHeader>
|
||||
<SectionHeader>{t('pages.crash.recovery.restore-head')}</SectionHeader>
|
||||
<TextBlock>{t('pages.crash.recovery.restore-desc-1')}</TextBlock>
|
||||
<Scrollbar
|
||||
vertical={true}
|
||||
|
@ -270,21 +258,14 @@ function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) {
|
|||
<BackupActions>
|
||||
<Alert>
|
||||
<AlertTrigger asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={restored === 'in-progress'}
|
||||
>
|
||||
<Button size="small" disabled={restored === 'in-progress'}>
|
||||
{t('pages.crash.recovery.restore-button')}
|
||||
</Button>
|
||||
</AlertTrigger>
|
||||
<AlertContent
|
||||
variation="danger"
|
||||
title={t(
|
||||
'pages.crash.recovery.restore-confirm-title',
|
||||
)}
|
||||
description={t(
|
||||
'pages.crash.recovery.restore-confirm-body',
|
||||
)}
|
||||
title={t('pages.crash.recovery.restore-confirm-title')}
|
||||
description={t('pages.crash.recovery.restore-confirm-body')}
|
||||
actionText={t('pages.crash.recovery.restore-button')}
|
||||
actionButtonProps={{ variation: 'danger' }}
|
||||
showCancel={true}
|
||||
|
@ -390,10 +371,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
title={t('pages.crash.report.dialog-title')}
|
||||
closeButton={!waiting}
|
||||
>
|
||||
<DialogContent title={t('pages.crash.report.dialog-title')} closeButton={!waiting}>
|
||||
<form
|
||||
style={waiting ? { opacity: 0.7 } : {}}
|
||||
onSubmit={(e) => {
|
||||
|
@ -412,9 +390,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
|
|||
setSubmitted(true);
|
||||
}}
|
||||
>
|
||||
<TextBlock css={{ fontSize: '0.9em' }}>
|
||||
{t('pages.crash.report.thanks-line')}
|
||||
</TextBlock>
|
||||
<TextBlock css={{ fontSize: '0.9em' }}>{t('pages.crash.report.thanks-line')}</TextBlock>
|
||||
<TextBlock css={{ fontSize: '0.9em' }}>
|
||||
{t('pages.crash.report.transparency-line')}
|
||||
<ul>
|
||||
|
@ -436,9 +412,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
|
|||
</ul>
|
||||
</TextBlock>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="error-desc">
|
||||
{t('pages.crash.report.additional-label')}
|
||||
</Label>
|
||||
<Label htmlFor="error-desc">{t('pages.crash.report.additional-label')}</Label>
|
||||
<Textarea
|
||||
id="error-desc"
|
||||
rows={5}
|
||||
|
@ -460,9 +434,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
|
|||
setContactEnabled(!!ev);
|
||||
}}
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
{contactEnabled && <CheckIcon />}
|
||||
</CheckboxIndicator>
|
||||
<CheckboxIndicator>{contactEnabled && <CheckIcon />}</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
{t('pages.crash.report.email-label')}
|
||||
</FlexRow>
|
||||
|
@ -470,11 +442,7 @@ function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) {
|
|||
<InputBox
|
||||
type="email"
|
||||
id="error-contact"
|
||||
placeholder={
|
||||
contactEnabled
|
||||
? t('pages.crash.report.email-placeholder')
|
||||
: ''
|
||||
}
|
||||
placeholder={contactEnabled ? t('pages.crash.report.email-placeholder') : ''}
|
||||
value={contactInfo ?? ''}
|
||||
required={contactEnabled}
|
||||
disabled={!contactEnabled}
|
||||
|
@ -499,6 +467,7 @@ export default function ErrorWindow(): JSX.Element {
|
|||
const [reportDialogOpen, setReportDialogOpen] = useState(false);
|
||||
const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: One time setup
|
||||
useEffect(() => {
|
||||
void i18n.changeLanguage(localStorage.getItem('language') ?? 'en');
|
||||
void GetLastLogs().then((appLogs) => {
|
||||
|
@ -517,15 +486,8 @@ export default function ErrorWindow(): JSX.Element {
|
|||
|
||||
return (
|
||||
<Container id="app-container" className={theme}>
|
||||
<ReportDialog
|
||||
open={reportDialogOpen}
|
||||
onOpenChange={setReportDialogOpen}
|
||||
errorData={fatal}
|
||||
/>
|
||||
<RecoveryDialog
|
||||
open={recoveryDialogOpen}
|
||||
onOpenChange={setRecoveryDialogOpen}
|
||||
/>
|
||||
<ReportDialog open={reportDialogOpen} onOpenChange={setReportDialogOpen} errorData={fatal} />
|
||||
<RecoveryDialog open={recoveryDialogOpen} onOpenChange={setRecoveryDialogOpen} />
|
||||
<LanguageSelector>
|
||||
<MultiToggle
|
||||
value={i18n.resolvedLanguage}
|
||||
|
@ -584,10 +546,7 @@ export default function ErrorWindow(): JSX.Element {
|
|||
/>
|
||||
</TextBlock>
|
||||
<FlexRow align="left" spacing={1} css={{ paddingTop: '0.5rem' }}>
|
||||
<Button
|
||||
variation={'danger'}
|
||||
onClick={() => setReportDialogOpen(true)}
|
||||
>
|
||||
<Button variation={'danger'} onClick={() => setReportDialogOpen(true)}>
|
||||
{t('pages.crash.button-report')}
|
||||
</Button>
|
||||
<Button onClick={() => setRecoveryDialogOpen(true)}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { VariantProps } from '@stitches/react';
|
||||
import type { VariantProps } from '@stitches/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertOverlay,
|
||||
|
@ -38,18 +38,12 @@ function AlertContent({
|
|||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal
|
||||
container={document.getElementById('app-container')}
|
||||
>
|
||||
<AlertDialogPrimitive.Portal container={document.getElementById('app-container')}>
|
||||
<AlertOverlay />
|
||||
<AlertContainer variation={variation ?? 'default'}>
|
||||
{title && (
|
||||
<AlertTitle variation={variation ?? 'default'}>{title}</AlertTitle>
|
||||
)}
|
||||
{title && <AlertTitle variation={variation ?? 'default'}>{title}</AlertTitle>}
|
||||
{description && (
|
||||
<AlertDescription variation={variation ?? 'default'}>
|
||||
{description}
|
||||
</AlertDescription>
|
||||
<AlertDescription variation={variation ?? 'default'}>{description}</AlertDescription>
|
||||
)}
|
||||
{children}
|
||||
<AlertActions>
|
||||
|
|
|
@ -3,11 +3,11 @@ import { BrowserOpenURL } from '@wailsapp/runtime';
|
|||
|
||||
function BrowserLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
if (!props.href) {
|
||||
return <a {...props}></a>;
|
||||
return <a {...props} />;
|
||||
}
|
||||
|
||||
const properties = { ...props };
|
||||
delete properties.href;
|
||||
properties.href = undefined;
|
||||
return (
|
||||
<a
|
||||
{...properties}
|
||||
|
@ -15,7 +15,7 @@ function BrowserLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|||
onClick={() => {
|
||||
BrowserOpenURL(props.href);
|
||||
}}
|
||||
></a>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||
import React, { ReactElement, useState } from 'react';
|
||||
import { SortFunction } from '~/lib/types';
|
||||
import type React from 'react';
|
||||
import { type ReactElement, useState } from 'react';
|
||||
import type { SortFunction } from '~/lib/types';
|
||||
import { styled } from '../theme';
|
||||
import { Table, TableHeader } from '../theme/table';
|
||||
import PageList from './PageList';
|
||||
|
@ -106,11 +107,7 @@ export function DataTable<T>({
|
|||
<Sortable onClick={() => changeSort(key as keyof T)}>
|
||||
{title}
|
||||
{sorting.key === key &&
|
||||
(sorting.order === 'asc' ? (
|
||||
<ChevronUpIcon />
|
||||
) : (
|
||||
<ChevronDownIcon />
|
||||
))}
|
||||
(sorting.order === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />)}
|
||||
</Sortable>
|
||||
) : (
|
||||
title
|
||||
|
|
|
@ -22,9 +22,7 @@ function DialogContent({
|
|||
closeButton,
|
||||
}: React.PropsWithChildren<DialogProps>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal
|
||||
container={document.getElementById('app-container')}
|
||||
>
|
||||
<DialogPrimitive.Portal container={document.getElementById('app-container')}>
|
||||
<DialogOverlay />
|
||||
<DialogContainer>
|
||||
{title && (
|
||||
|
|
|
@ -3,14 +3,7 @@ import { EventsEmit, EventsOff, EventsOn } from '@wailsapp/runtime';
|
|||
import { CheckCircledIcon, CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DialogContent from './DialogContent';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogDescription,
|
||||
TextBlock,
|
||||
styled,
|
||||
} from '../theme';
|
||||
import { Button, Dialog, DialogActions, DialogDescription, TextBlock, styled } from '../theme';
|
||||
import BrowserLink from './BrowserLink';
|
||||
|
||||
interface AuthRequest {
|
||||
|
@ -27,13 +20,7 @@ interface AppInfo {
|
|||
icon: string;
|
||||
}
|
||||
|
||||
const appInfoKeys: Array<keyof AppInfo> = [
|
||||
'name',
|
||||
'author',
|
||||
'verificationCode',
|
||||
'url',
|
||||
'icon',
|
||||
];
|
||||
const appInfoKeys: Array<keyof AppInfo> = ['name', 'author', 'verificationCode', 'url', 'icon'];
|
||||
|
||||
const AppCard = styled('div', {
|
||||
display: 'grid',
|
||||
|
@ -80,11 +67,11 @@ function parseAppInfo(message: Record<string, unknown>): AppInfo {
|
|||
icon: '',
|
||||
};
|
||||
|
||||
appInfoKeys.forEach((key) => {
|
||||
for (const key of appInfoKeys) {
|
||||
if (key in message) {
|
||||
info[key] = String(message[key]) || info[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
@ -97,10 +84,7 @@ export default function InteractiveAuthDialog() {
|
|||
EventsOn(
|
||||
'interactiveAuth',
|
||||
(uid: number, message: Record<string, unknown>, callbackID: string) => {
|
||||
setRequests([
|
||||
...requests,
|
||||
{ uid, info: parseAppInfo(message), callbackID },
|
||||
]);
|
||||
setRequests([...requests, { uid, info: parseAppInfo(message), callbackID }]);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
|
@ -126,9 +110,7 @@ export default function InteractiveAuthDialog() {
|
|||
<TextBlock>{t('pages.interactive-auth.info-present')}</TextBlock>
|
||||
<AppCard>
|
||||
{info.icon && <AppIcon src={info.icon} />}
|
||||
<AppName>
|
||||
{info.name || t('pages.interactive-auth.unknown-name')}
|
||||
</AppName>
|
||||
<AppName>{info.name || t('pages.interactive-auth.unknown-name')}</AppName>
|
||||
{info.author && <AppInfo>{info.author}</AppInfo>}
|
||||
{info.url && (
|
||||
<AppInfo>
|
||||
|
@ -138,25 +120,17 @@ export default function InteractiveAuthDialog() {
|
|||
</AppCard>
|
||||
{info.verificationCode && (
|
||||
<>
|
||||
<TextBlock>
|
||||
{t('pages.interactive-auth.verification-code')}
|
||||
</TextBlock>
|
||||
<TextBlock>{t('pages.interactive-auth.verification-code')}</TextBlock>
|
||||
<AppCode>{info.verificationCode}</AppCode>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variation="primary"
|
||||
onClick={() => answerAuthRequest(callbackID, true)}
|
||||
>
|
||||
<Button variation="primary" onClick={() => answerAuthRequest(callbackID, true)}>
|
||||
<CheckCircledIcon />
|
||||
{t('pages.interactive-auth.allow')}
|
||||
</Button>
|
||||
<Button
|
||||
variation="danger"
|
||||
onClick={() => answerAuthRequest(callbackID, false)}
|
||||
>
|
||||
<Button variation="danger" onClick={() => answerAuthRequest(callbackID, false)}>
|
||||
<CrossCircledIcon />
|
||||
{t('pages.interactive-auth.deny')}
|
||||
</Button>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
// @ts-expect-error Asset import
|
||||
import spinner from '~/assets/icon-loading.svg';
|
||||
|
@ -39,11 +39,7 @@ interface LoadingProps {
|
|||
theme: string;
|
||||
}
|
||||
|
||||
export default function Loading({
|
||||
message,
|
||||
size,
|
||||
theme,
|
||||
}: React.PropsWithChildren<LoadingProps>) {
|
||||
export default function Loading({ message, size, theme }: React.PropsWithChildren<LoadingProps>) {
|
||||
return (
|
||||
<LoadingDiv size={size} className={theme}>
|
||||
<Spinner src={spinner as string} alt="Loading..." />
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useAppSelector } from 'src/store';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { delay } from '~/lib/time';
|
||||
import { ProcessedLogEntry } from '~/store/logging/reducer';
|
||||
import type { ProcessedLogEntry } from '~/store/logging/reducer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContainer,
|
||||
|
@ -376,14 +376,10 @@ function LogDialog({ initialFilter }: LogDialogProps) {
|
|||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const filtered = logEntries.filter(
|
||||
(entry) => entry.level in filter && filter[entry.level],
|
||||
);
|
||||
const filtered = logEntries.filter((entry) => entry.level in filter && filter[entry.level]);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Portal
|
||||
container={document.getElementById('app-container')}
|
||||
>
|
||||
<DialogPrimitive.Portal container={document.getElementById('app-container')}>
|
||||
<DialogOverlay />
|
||||
<DialogContainer style={{ padding: '0.5rem' }}>
|
||||
<DialogTitle
|
||||
|
@ -397,13 +393,13 @@ function LogDialog({ initialFilter }: LogDialogProps) {
|
|||
{t('logging.dialog-title')}
|
||||
<MultiToggle
|
||||
type="multiple"
|
||||
aria-label={t(`logging.levelFilter`)}
|
||||
aria-label={t('logging.levelFilter')}
|
||||
value={enabled}
|
||||
onValueChange={(values: LogLevel[]) => {
|
||||
const newFilter = { ...emptyFilter };
|
||||
values.forEach((level) => {
|
||||
for (const level of values) {
|
||||
newFilter[level] = true;
|
||||
});
|
||||
}
|
||||
setFilter(newFilter);
|
||||
}}
|
||||
>
|
||||
|
@ -425,10 +421,7 @@ function LogDialog({ initialFilter }: LogDialogProps) {
|
|||
</IconButton>
|
||||
</DialogPrimitive.DialogClose>
|
||||
</DialogTitle>
|
||||
<Scrollbar
|
||||
vertical={true}
|
||||
viewport={{ maxHeight: 'calc(80vh - 100px)' }}
|
||||
>
|
||||
<Scrollbar vertical={true} viewport={{ maxHeight: 'calc(80vh - 100px)' }}>
|
||||
<LogEntriesContainer>
|
||||
{filtered.reverse().map((entry) => (
|
||||
<LogItem key={entry.id} data={entry} />
|
||||
|
@ -459,17 +452,13 @@ function LogViewer() {
|
|||
return (
|
||||
<div>
|
||||
<Floating>
|
||||
{levels.map((level) =>
|
||||
level in count && count[level] > 0 ? (
|
||||
<LogBubble
|
||||
key={level}
|
||||
level={level}
|
||||
onClick={() => setActiveDialog(level)}
|
||||
>
|
||||
{levels
|
||||
.filter((level) => level in count && count[level] > 0)
|
||||
.map((level) => (
|
||||
<LogBubble key={level} level={level} onClick={() => setActiveDialog(level)}>
|
||||
{count[level]}
|
||||
</LogBubble>
|
||||
) : null,
|
||||
)}
|
||||
))}
|
||||
</Floating>
|
||||
|
||||
<Dialog
|
||||
|
@ -482,9 +471,7 @@ function LogViewer() {
|
|||
}}
|
||||
>
|
||||
{activeDialog ? (
|
||||
<LogDialog
|
||||
initialFilter={levels.slice(levels.indexOf(activeDialog))}
|
||||
/>
|
||||
<LogDialog initialFilter={levels.slice(levels.indexOf(activeDialog))} />
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
|
|
|
@ -75,9 +75,7 @@ function PageList({
|
|||
</ToolbarComboBox>
|
||||
</ToolbarSection>
|
||||
<ToolbarSection>
|
||||
<div style={{ padding: '0 0.25rem' }}>
|
||||
{t('pagination.page', { page: current })}
|
||||
</div>
|
||||
<div style={{ padding: '0 0.25rem' }}>{t('pagination.page', { page: current })}</div>
|
||||
{current > min ? (
|
||||
<ToolbarButton
|
||||
className="button pagination-link"
|
||||
|
@ -88,9 +86,7 @@ function PageList({
|
|||
{min}
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
{current > min + 2 ? (
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
) : null}
|
||||
{current > min + 2 ? <span className="pagination-ellipsis">…</span> : null}
|
||||
|
||||
{current > min + 1 ? (
|
||||
<ToolbarButton
|
||||
|
@ -138,9 +134,7 @@ function PageList({
|
|||
{current + 1}
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
{current < max - 2 ? (
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
) : null}
|
||||
{current < max - 2 ? <span className="pagination-ellipsis">…</span> : null}
|
||||
{current < max - 1 ? (
|
||||
<ToolbarButton
|
||||
className="button pagination-link"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||||
|
||||
|
@ -235,7 +236,7 @@ function SidebarLink({ route: { title, url, icon } }: { route: Route }) {
|
|||
|
||||
function parseVersion(semanticVersion: string) {
|
||||
const [version, prerelease] = semanticVersion.split('-', 2);
|
||||
const [major, minor, patch] = version.split('.').map((x) => parseInt(x, 10));
|
||||
const [major, minor, patch] = version.split('.').map((x) => Number.parseInt(x, 10));
|
||||
return { major, minor, patch, prerelease };
|
||||
}
|
||||
|
||||
|
@ -275,33 +276,29 @@ interface UpdateInfo {
|
|||
latest: VersionInfo;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
sections,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
export default function Sidebar({ sections }: SidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const resolved = useResolvedPath('/about');
|
||||
const matchApp = useMatch({ path: resolved.pathname, end: true });
|
||||
const version = useAppSelector((state) => state.server.version?.release);
|
||||
const [lastVersion, setLastVersion] = useState<{ url: string; name: string }>(
|
||||
null,
|
||||
);
|
||||
const dev = version && version.startsWith('v0.0.0');
|
||||
const [lastVersion, setLastVersion] = useState<{ url: string; name: string }>(null);
|
||||
const dev = version?.startsWith('v0.0.0');
|
||||
const prerelease = !dev && version.includes('-', 6);
|
||||
|
||||
async function fetchLastVersion() {
|
||||
try {
|
||||
const req = await fetch('https://strimertul.stream/update.json');
|
||||
const data = (await req.json()) as UpdateInfo;
|
||||
setLastVersion(prerelease ? data.latest : data.stable);
|
||||
} catch (e) {
|
||||
// TODO Report error nicely
|
||||
console.warn('Failed checking upstream for latest version', e);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLastVersion() {
|
||||
try {
|
||||
const req = await fetch('https://strimertul.stream/update.json');
|
||||
const data = (await req.json()) as UpdateInfo;
|
||||
setLastVersion(prerelease ? data.latest : data.stable);
|
||||
} catch (e) {
|
||||
// TODO Report error nicely
|
||||
console.warn('Failed checking upstream for latest version', e);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchLastVersion();
|
||||
}, []);
|
||||
}, [prerelease]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
|
@ -312,19 +309,14 @@ export default function Sidebar({
|
|||
<AppLogo src={logo as string} />
|
||||
<span>{APPNAME}</span>
|
||||
</AppName>
|
||||
<VersionLabel>
|
||||
{version && !dev ? version : t('debug.dev-build')}
|
||||
</VersionLabel>
|
||||
<VersionLabel>{version && !dev ? version : t('debug.dev-build')}</VersionLabel>
|
||||
</AppLink>
|
||||
{!dev &&
|
||||
version &&
|
||||
lastVersion &&
|
||||
!hasLatestOrBeta(version, lastVersion.name) && (
|
||||
<UpdateButton href={lastVersion.url}>
|
||||
<ExternalLinkIcon />
|
||||
<span>{t('menu.messages.update-available')}</span>
|
||||
</UpdateButton>
|
||||
)}
|
||||
{!dev && version && lastVersion && !hasLatestOrBeta(version, lastVersion.name) && (
|
||||
<UpdateButton href={lastVersion.url}>
|
||||
<ExternalLinkIcon />
|
||||
<span>{t('menu.messages.update-available')}</span>
|
||||
</UpdateButton>
|
||||
)}
|
||||
</Header>
|
||||
{sections.map(({ title: sectionTitle, short, links }) => (
|
||||
<MenuSection key={sectionTitle}>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { GetTwitchLoggedUser } from '@wailsapp/go/main/App';
|
||||
import { helix } from '@wailsapp/go/models';
|
||||
import type { helix } from '@wailsapp/go/models';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '~/store';
|
||||
import { TextBlock, styled } from '../theme';
|
||||
import { useKilovoltClient } from '~/lib/react';
|
||||
|
||||
interface SyncError {
|
||||
ok: false;
|
||||
|
@ -28,24 +29,21 @@ interface TwitchUserBlockProps {
|
|||
noUserMessage: string;
|
||||
}
|
||||
|
||||
export default function TwitchUserBlock({
|
||||
authKey,
|
||||
noUserMessage,
|
||||
}: TwitchUserBlockProps) {
|
||||
export default function TwitchUserBlock({ authKey, noUserMessage }: TwitchUserBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [user, setUser] = useState<helix.User | SyncError>(null);
|
||||
const kv = useAppSelector((state) => state.api.client);
|
||||
|
||||
const getUserInfo = async () => {
|
||||
try {
|
||||
const res = await GetTwitchLoggedUser(authKey);
|
||||
setUser(res);
|
||||
} catch (e) {
|
||||
setUser({ ok: false, error: (e as Error).message });
|
||||
}
|
||||
};
|
||||
const kv = useKilovoltClient();
|
||||
|
||||
useEffect(() => {
|
||||
const getUserInfo = async () => {
|
||||
try {
|
||||
const res = await GetTwitchLoggedUser(authKey);
|
||||
setUser(res);
|
||||
} catch (e) {
|
||||
setUser({ ok: false, error: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
// Get user info
|
||||
void getUserInfo();
|
||||
|
||||
|
@ -56,15 +54,13 @@ export default function TwitchUserBlock({
|
|||
return () => {
|
||||
void kv.unsubscribeKey(authKey, onKeyChange);
|
||||
};
|
||||
}, []);
|
||||
}, [authKey]);
|
||||
|
||||
if (user !== null) {
|
||||
if ('id' in user) {
|
||||
return (
|
||||
<TwitchUser>
|
||||
<TextBlock>
|
||||
{t('pages.twitch-settings.events.authenticated-as')}
|
||||
</TextBlock>
|
||||
<TextBlock>{t('pages.twitch-settings.events.authenticated-as')}</TextBlock>
|
||||
<TwitchPic
|
||||
src={user.profile_image_url}
|
||||
alt={t('pages.twitch-settings.events.profile-picture')}
|
||||
|
|
|
@ -2,13 +2,11 @@
|
|||
// Allows to have a input with text manipulation (e.g. sanitation) without
|
||||
// messing with the cursor
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const ControlledInput = (
|
||||
props: React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>,
|
||||
props: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
) => {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const [cursor, setCursor] = useState<number>(null);
|
||||
|
@ -19,7 +17,7 @@ const ControlledInput = (
|
|||
if (input) {
|
||||
input.setSelectionRange(cursor, cursor);
|
||||
}
|
||||
}, [ref, cursor, value]);
|
||||
}, [cursor]);
|
||||
|
||||
return (
|
||||
<input
|
||||
|
|
|
@ -53,7 +53,7 @@ export default function Interval({
|
|||
}}
|
||||
value={num ?? ''}
|
||||
onChange={(ev) => {
|
||||
const parsedNum = parseInt(ev.target.value, 10);
|
||||
const parsedNum = Number.parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(parsedNum)) {
|
||||
return;
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export default function Interval({
|
|||
value={mult.toString() ?? ''}
|
||||
disabled={!active}
|
||||
onChange={(ev) => {
|
||||
const parsedMult = parseInt(ev.target.value, 10);
|
||||
const parsedMult = Number.parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(parsedMult)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -11,22 +11,13 @@ export interface MultiInputProps {
|
|||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function MultiInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
required,
|
||||
disabled,
|
||||
}: MultiInputProps) {
|
||||
function MultiInput({ value, placeholder, onChange, required, disabled }: MultiInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{value.map((message, index) => (
|
||||
<FlexRow
|
||||
key={`${value.length}-${index}`}
|
||||
css={{ marginTop: '0.5rem', flex: 1 }}
|
||||
>
|
||||
<FlexRow key={`${value.length}-${index}`} css={{ marginTop: '0.5rem', flex: 1 }}>
|
||||
<FlexRow border="form" css={{ flex: 1, alignItems: 'stretch' }}>
|
||||
<Textarea
|
||||
disabled={disabled}
|
||||
|
|
|
@ -6,20 +6,11 @@ export interface PasswordFieldProps {
|
|||
|
||||
function PasswordField(
|
||||
props: PasswordFieldProps &
|
||||
React.PropsWithChildren<
|
||||
React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>
|
||||
>,
|
||||
React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
) {
|
||||
const subprops = { ...props };
|
||||
delete subprops.reveal;
|
||||
return (
|
||||
<input type={props.reveal ? 'text' : 'password'} {...subprops}>
|
||||
{props.children}
|
||||
</input>
|
||||
);
|
||||
subprops.reveal = undefined;
|
||||
return <input type={props.reveal ? 'text' : 'password'} {...subprops} />;
|
||||
}
|
||||
|
||||
const PurePasswordField = React.memo(PasswordField);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import React, { type ReactElement } from 'react';
|
||||
import {
|
||||
Root,
|
||||
Item,
|
||||
Indicator,
|
||||
RadioGroupProps as RootProps,
|
||||
type RadioGroupProps as RootProps,
|
||||
} from '@radix-ui/react-radio-group';
|
||||
import { lightMode, styled } from '~/ui/theme';
|
||||
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RequestStatus } from '~/store/api/types';
|
||||
import type { RequestStatus } from '~/store/api/types';
|
||||
import { Button } from '../../theme';
|
||||
|
||||
interface SaveButtonProps {
|
||||
status: RequestStatus;
|
||||
}
|
||||
|
||||
function SaveButton(
|
||||
props: SaveButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
function SaveButton(props: SaveButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (props.status?.type) {
|
||||
|
|
|
@ -1,13 +1,5 @@
|
|||
import {
|
||||
ChatBubbleIcon,
|
||||
DiscordLogoIcon,
|
||||
EnvelopeClosedIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
ChannelList,
|
||||
Channel,
|
||||
ChannelLink,
|
||||
} from '~/ui/pages/system/Strimertul';
|
||||
import { ChatBubbleIcon, DiscordLogoIcon, EnvelopeClosedIcon } from '@radix-ui/react-icons';
|
||||
import { ChannelList, Channel, ChannelLink } from '~/ui/pages/system/Strimertul';
|
||||
|
||||
export const Channels = (
|
||||
<ChannelList>
|
||||
|
|
|
@ -9,9 +9,7 @@ export interface RevealLinkProps {
|
|||
|
||||
function RevealLink({ value, setter }: RevealLinkProps) {
|
||||
const { t } = useTranslation();
|
||||
const text = value
|
||||
? t('form-actions.password-hide')
|
||||
: t('form-actions.password-reveal');
|
||||
const text = value ? t('form-actions.password-hide') : t('form-actions.password-reveal');
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
@ -40,9 +40,7 @@ function Scrollbar({
|
|||
}: React.PropsWithChildren<ScrollbarProps>): React.ReactElement {
|
||||
return (
|
||||
<ScrollArea.Root style={root ?? {}}>
|
||||
<ScrollArea.Viewport style={viewport ?? {}}>
|
||||
{children}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Viewport style={viewport ?? {}}>{children}</ScrollArea.Viewport>
|
||||
{vertical ? (
|
||||
<StyledScrollbar orientation="vertical" style={{ width: '10px' }}>
|
||||
<StyledThumb />
|
||||
|
|
|
@ -5,31 +5,16 @@ import {
|
|||
UpdateIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
EventSubNotification,
|
||||
EventSubNotificationType,
|
||||
unwrapEvent,
|
||||
} from '~/lib/eventSub';
|
||||
import { useLiveKey, useModule } from '~/lib/react';
|
||||
import { type EventSubNotification, EventSubNotificationType, unwrapEvent } from '~/lib/eventSub';
|
||||
import { useKilovoltClient, useLiveKey, useModule } from '~/lib/react';
|
||||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import * as HoverCard from '@radix-ui/react-hover-card';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { main } from '@wailsapp/go/models';
|
||||
import {
|
||||
GetLastLogs,
|
||||
GetProblems,
|
||||
GetTwitchAuthURL,
|
||||
} from '@wailsapp/go/main/App';
|
||||
import type { main } from '@wailsapp/go/models';
|
||||
import { GetLastLogs, GetProblems, GetTwitchAuthURL } from '@wailsapp/go/main/App';
|
||||
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
|
||||
import {
|
||||
PageContainer,
|
||||
SectionHeader,
|
||||
styled,
|
||||
TextBlock,
|
||||
theme,
|
||||
TooltipContent,
|
||||
} from '../theme';
|
||||
import { PageContainer, SectionHeader, styled, TextBlock, theme, TooltipContent } from '../theme';
|
||||
import BrowserLink from '../components/BrowserLink';
|
||||
import Scrollbar from '../components/utils/Scrollbar';
|
||||
import RevealLink from '../components/utils/RevealLink';
|
||||
|
@ -147,26 +132,19 @@ const supportedMessages: EventSubNotificationType[] = [
|
|||
];
|
||||
|
||||
const eventSubKeyFunction = (ev: EventSubNotification) =>
|
||||
`${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(
|
||||
ev.event,
|
||||
)}`;
|
||||
`${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(ev.event)}`;
|
||||
|
||||
function TwitchEvent({ data }: { data: EventSubNotification }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useAppSelector((state) => state.api.client);
|
||||
|
||||
const replay = () => {
|
||||
void client.putJSON(
|
||||
`twitch/ev/eventsub-event/${data.subscription.type}`,
|
||||
data,
|
||||
);
|
||||
void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, data);
|
||||
};
|
||||
|
||||
let content: JSX.Element | string;
|
||||
const message = unwrapEvent(data);
|
||||
let date = data.date
|
||||
? new Date(data.date)
|
||||
: new Date(data.subscription.created_at);
|
||||
let date = data.date ? new Date(data.date) : new Date(data.subscription.created_at);
|
||||
switch (message.type) {
|
||||
case EventSubNotificationType.Followed: {
|
||||
content = (
|
||||
|
@ -207,10 +185,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|||
case EventSubNotificationType.StreamWentOnline: {
|
||||
content = (
|
||||
<>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={'pages.dashboard.twitch-events.events.stream-start'}
|
||||
/>
|
||||
<Trans t={t} i18nKey={'pages.dashboard.twitch-events.events.stream-start'} />
|
||||
</>
|
||||
);
|
||||
date = new Date(message.event.started_at);
|
||||
|
@ -219,10 +194,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|||
case EventSubNotificationType.StreamWentOffline: {
|
||||
content = (
|
||||
<>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={'pages.dashboard.twitch-events.events.stream-stop'}
|
||||
/>
|
||||
<Trans t={t} i18nKey={'pages.dashboard.twitch-events.events.stream-stop'} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
@ -230,10 +202,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|||
case EventSubNotificationType.ChannelUpdated: {
|
||||
content = (
|
||||
<>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={'pages.dashboard.twitch-events.events.channel-updated'}
|
||||
/>
|
||||
<Trans t={t} i18nKey={'pages.dashboard.twitch-events.events.channel-updated'} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
@ -345,10 +314,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|||
return (
|
||||
<TwitchEventContainer>
|
||||
<TwitchEventContent>{content}</TwitchEventContent>
|
||||
<TwitchEventTime
|
||||
title={date?.toLocaleString()}
|
||||
dateTime={message.subscription.created_at}
|
||||
>
|
||||
<TwitchEventTime title={date?.toLocaleString()} dateTime={message.subscription.created_at}>
|
||||
{date?.toLocaleTimeString()}
|
||||
</TwitchEventTime>
|
||||
<TwitchEventActions>
|
||||
|
@ -393,9 +359,7 @@ interface TwitchEventLogProps {
|
|||
dateMarker: number;
|
||||
}
|
||||
|
||||
type EventItem =
|
||||
| { type: 'marker' }
|
||||
| { type: 'event'; event: EventSubNotification };
|
||||
type EventItem = { type: 'marker' } | { type: 'event'; event: EventSubNotification };
|
||||
|
||||
function TwitchEventLog({ events, dateMarker }: TwitchEventLogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
@ -406,8 +370,7 @@ function TwitchEventLog({ events, dateMarker }: TwitchEventLogProps) {
|
|||
.sort((a, b) =>
|
||||
a.date && b.date
|
||||
? Date.parse(b.date) - Date.parse(a.date)
|
||||
: Date.parse(b.subscription.created_at) -
|
||||
Date.parse(a.subscription.created_at),
|
||||
: Date.parse(b.subscription.created_at) - Date.parse(a.subscription.created_at),
|
||||
)
|
||||
.map((ev) => ({ type: 'event', event: ev }));
|
||||
|
||||
|
@ -436,9 +399,7 @@ function TwitchEventLog({ events, dateMarker }: TwitchEventLogProps) {
|
|||
</SectionHeader>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<TooltipContent>
|
||||
{t('pages.dashboard.twitch-events.warning')}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t('pages.dashboard.twitch-events.warning')}</TooltipContent>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
<Scrollbar vertical={true} viewport={{ maxHeight: '250px' }}>
|
||||
|
@ -452,12 +413,7 @@ function TwitchEventLog({ events, dateMarker }: TwitchEventLogProps) {
|
|||
</SessionMarker>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TwitchEvent
|
||||
key={eventSubKeyFunction(ev.event)}
|
||||
data={ev.event}
|
||||
/>
|
||||
);
|
||||
return <TwitchEvent key={eventSubKeyFunction(ev.event)} data={ev.event} />;
|
||||
}
|
||||
})}
|
||||
</EventListContainer>
|
||||
|
@ -503,7 +459,7 @@ function TwitchStreamStatus({ info }: { info: StreamInfo }) {
|
|||
function TwitchSection() {
|
||||
const { t } = useTranslation();
|
||||
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
||||
const kv = useAppSelector((state) => state.api.client);
|
||||
const kv = useKilovoltClient();
|
||||
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
|
||||
const [oldestLog, setOldestLog] = useState(Date.now());
|
||||
|
||||
|
@ -519,32 +475,30 @@ function TwitchSection() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const keyfn = (ev: EventSubNotification) => JSON.stringify(ev);
|
||||
useEffect(() => {
|
||||
const keyfn = (ev: EventSubNotification) => JSON.stringify(ev);
|
||||
|
||||
const addTwitchEvents = (events: EventSubNotification[]) => {
|
||||
setTwitchEvents((currentEvents) => {
|
||||
const allEvents = currentEvents.concat(events);
|
||||
const eventKeys = allEvents.map(keyfn);
|
||||
const addTwitchEvents = (events: EventSubNotification[]) => {
|
||||
setTwitchEvents((currentEvents) => {
|
||||
const allEvents = currentEvents.concat(events);
|
||||
const eventKeys = allEvents.map(keyfn);
|
||||
|
||||
// Clean up duplicates before setting to state
|
||||
const updatedEvents = allEvents.filter(
|
||||
(ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos,
|
||||
// Clean up duplicates before setting to state
|
||||
const updatedEvents = allEvents.filter((ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos);
|
||||
|
||||
return updatedEvents;
|
||||
});
|
||||
};
|
||||
|
||||
const loadRecentEvents = async () => {
|
||||
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
|
||||
const events = Object.values(keymap).flatMap(
|
||||
(value) => JSON.parse(value) as EventSubNotification[],
|
||||
);
|
||||
|
||||
return updatedEvents;
|
||||
});
|
||||
};
|
||||
addTwitchEvents(events);
|
||||
};
|
||||
|
||||
const loadRecentEvents = async () => {
|
||||
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
|
||||
const events = Object.values(keymap)
|
||||
.map((value) => JSON.parse(value) as EventSubNotification[])
|
||||
.flat();
|
||||
|
||||
addTwitchEvents(events);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecentEvents();
|
||||
|
||||
const onKeyChange = (value: string) => {
|
||||
|
@ -564,17 +518,13 @@ function TwitchSection() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader spacing="none">
|
||||
{t('pages.dashboard.twitch-status')}
|
||||
</SectionHeader>
|
||||
<SectionHeader spacing="none">{t('pages.dashboard.twitch-status')}</SectionHeader>
|
||||
{twitchInfo && twitchInfo.length > 0 ? (
|
||||
<TwitchStreamStatus info={twitchInfo[0]} />
|
||||
) : (
|
||||
<TextBlock>{t('pages.dashboard.not-live')}</TextBlock>
|
||||
)}
|
||||
{twitchEvents ? (
|
||||
<TwitchEventLog events={twitchEvents} dateMarker={oldestLog} />
|
||||
) : null}
|
||||
{twitchEvents ? <TwitchEventLog events={twitchEvents} dateMarker={oldestLog} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -648,7 +598,7 @@ function ProblemList() {
|
|||
onClick={() => {
|
||||
void reauthenticate();
|
||||
}}
|
||||
></a>
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,23 +1,12 @@
|
|||
import {
|
||||
ExclamationTriangleIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { ExclamationTriangleIcon, ExternalLinkIcon } from '@radix-ui/react-icons';
|
||||
import { keyframes } from '@stitches/react';
|
||||
import { GetTwitchLoggedUser, GetTwitchAuthURL } from '@wailsapp/go/main/App';
|
||||
import { helix } from '@wailsapp/go/models';
|
||||
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useModule } from '~/lib/react';
|
||||
import {
|
||||
checkTwitchKeys,
|
||||
startAuthFlow,
|
||||
TwitchCredentials,
|
||||
} from '~/lib/twitch';
|
||||
import { checkTwitchKeys, startAuthFlow } from '~/lib/twitch';
|
||||
import { languages } from '~/locale/languages';
|
||||
import { RootState, useAppDispatch } from '~/store';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
|
||||
// @ts-expect-error Asset import
|
||||
|
@ -221,16 +210,12 @@ const steps = [
|
|||
|
||||
const stepI18n = {
|
||||
[OnboardingSteps.Landing]: 'pages.onboarding.sections.landing',
|
||||
[OnboardingSteps.TwitchIntegration]:
|
||||
'pages.onboarding.sections.twitch-config',
|
||||
[OnboardingSteps.TwitchIntegration]: 'pages.onboarding.sections.twitch-config',
|
||||
[OnboardingSteps.TwitchEvents]: 'pages.onboarding.sections.twitch-events',
|
||||
[OnboardingSteps.Done]: 'pages.onboarding.sections.done',
|
||||
};
|
||||
|
||||
const maxKeys = languages.reduce(
|
||||
(current, it) => Math.max(current, it.keys),
|
||||
0,
|
||||
);
|
||||
const maxKeys = languages.reduce((current, it) => Math.max(current, it.keys), 0);
|
||||
|
||||
type TestResult = { open: boolean; error?: Error };
|
||||
|
||||
|
@ -266,10 +251,7 @@ function TwitchIntegrationStep() {
|
|||
setTesting(true);
|
||||
if (twitchConfig) {
|
||||
try {
|
||||
await checkTwitchKeys(
|
||||
twitchConfig.api_client_id,
|
||||
twitchConfig.api_client_secret,
|
||||
);
|
||||
await checkTwitchKeys(twitchConfig.api_client_id, twitchConfig.api_client_secret);
|
||||
void dispatch(
|
||||
setTwitchConfig({
|
||||
...twitchConfig,
|
||||
|
@ -293,8 +275,7 @@ function TwitchIntegrationStep() {
|
|||
void dispatch(
|
||||
setUiConfig({
|
||||
...uiConfig,
|
||||
onboardingStatus:
|
||||
steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
|
||||
onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -342,9 +323,7 @@ function TwitchIntegrationStep() {
|
|||
</TwitchStep>
|
||||
</TwitchStepList>
|
||||
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
|
||||
<Label htmlFor="clientid">
|
||||
{t('pages.twitch-settings.app-client-id')}
|
||||
</Label>
|
||||
<Label htmlFor="clientid">{t('pages.twitch-settings.app-client-id')}</Label>
|
||||
<InputBox
|
||||
type="text"
|
||||
id="clientid"
|
||||
|
@ -365,10 +344,7 @@ function TwitchIntegrationStep() {
|
|||
<Field size="fullWidth">
|
||||
<Label htmlFor="clientsecret">
|
||||
{t('pages.twitch-settings.app-client-secret')}
|
||||
<RevealLink
|
||||
value={revealClientSecret}
|
||||
setter={setRevealClientSecret}
|
||||
/>
|
||||
<RevealLink value={revealClientSecret} setter={setRevealClientSecret} />
|
||||
</Label>
|
||||
<PasswordInputBox
|
||||
reveal={revealClientSecret}
|
||||
|
@ -442,8 +418,7 @@ function TwitchEventsStep() {
|
|||
await dispatch(
|
||||
setUiConfig({
|
||||
...uiConfig,
|
||||
onboardingStatus:
|
||||
steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
|
||||
onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
@ -462,9 +437,7 @@ function TwitchEventsStep() {
|
|||
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<SectionHeader>
|
||||
{t('pages.twitch-settings.events.current-status')}
|
||||
</SectionHeader>
|
||||
<SectionHeader>{t('pages.twitch-settings.events.current-status')}</SectionHeader>
|
||||
<TwitchUserBlock
|
||||
authKey="twitch/auth-keys"
|
||||
noUserMessage={t('pages.twitch-settings.events.err-no-user')}
|
||||
|
@ -542,6 +515,7 @@ export default function OnboardingPage() {
|
|||
setAnimationItems(
|
||||
spinners.map((url, i) => (
|
||||
<Spinner
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Index is stable
|
||||
key={i}
|
||||
src={url}
|
||||
css={{
|
||||
|
@ -565,10 +539,7 @@ export default function OnboardingPage() {
|
|||
case OnboardingSteps.Landing:
|
||||
currentStepBody = (
|
||||
<ActionContainer>
|
||||
<Button
|
||||
css={{ width: '20vw', justifyContent: 'center' }}
|
||||
onClick={() => skip()}
|
||||
>
|
||||
<Button css={{ width: '20vw', justifyContent: 'center' }} onClick={() => skip()}>
|
||||
{t('pages.onboarding.skip-button')}
|
||||
</Button>
|
||||
<Button
|
||||
|
@ -609,9 +580,7 @@ export default function OnboardingPage() {
|
|||
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
||||
type="single"
|
||||
onValueChange={(newLang) => {
|
||||
void dispatch(
|
||||
setUiConfig({ ...uiConfig, language: newLang }),
|
||||
);
|
||||
void dispatch(setUiConfig({ ...uiConfig, language: newLang }));
|
||||
localStorage.setItem('language', newLang);
|
||||
}}
|
||||
>
|
||||
|
@ -621,9 +590,7 @@ export default function OnboardingPage() {
|
|||
aria-label={lang.name}
|
||||
value={lang.code}
|
||||
title={`${lang.name} ${
|
||||
lang.keys < maxKeys
|
||||
? `(${t('pages.uiconfig.partial-translation')})`
|
||||
: ''
|
||||
lang.keys < maxKeys ? `(${t('pages.uiconfig.partial-translation')})` : ''
|
||||
}`}
|
||||
>
|
||||
{lang.name}
|
||||
|
@ -666,9 +633,7 @@ export default function OnboardingPage() {
|
|||
}}
|
||||
/>
|
||||
</TextBlock>
|
||||
<TextBlock css={{ color: '$gray11' }}>
|
||||
{t('pages.onboarding.welcome-p2')}
|
||||
</TextBlock>
|
||||
<TextBlock css={{ color: '$gray11' }}>{t('pages.onboarding.welcome-p2')}</TextBlock>
|
||||
</HeroContent>
|
||||
</HeroContainer>
|
||||
) : (
|
||||
|
@ -686,8 +651,7 @@ export default function OnboardingPage() {
|
|||
void dispatch(
|
||||
setUiConfig({
|
||||
...uiConfig,
|
||||
onboardingStatus:
|
||||
steps.findIndex((val) => val === step) ?? 0,
|
||||
onboardingStatus: steps.findIndex((val) => val === step) ?? 0,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule, useStatus } from '~/lib/react';
|
||||
import { useModule, useTimedStatus } from '~/lib/react';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import {
|
||||
|
@ -24,9 +24,8 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
const { t } = useTranslation();
|
||||
const [config, setConfig, loadStatus] = useModule(modules.loyaltyConfig);
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useStatus(loadStatus.save);
|
||||
const busy =
|
||||
loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
|
||||
const status = useTimedStatus(loadStatus.save);
|
||||
const busy = loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
|
||||
|
||||
const active = config?.enabled ?? false;
|
||||
|
||||
|
@ -66,9 +65,7 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth">
|
||||
<Label htmlFor="currency">
|
||||
{t('pages.loyalty-settings.currency-name')}
|
||||
</Label>
|
||||
<Label htmlFor="currency">{t('pages.loyalty-settings.currency-name')}</Label>
|
||||
<InputBox
|
||||
type="text"
|
||||
id="currency"
|
||||
|
@ -85,17 +82,13 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
);
|
||||
}}
|
||||
/>
|
||||
<FieldNote>
|
||||
{t('pages.loyalty-settings.currency-name-hint')}
|
||||
</FieldNote>
|
||||
<FieldNote>{t('pages.loyalty-settings.currency-name-hint')}</FieldNote>
|
||||
</Field>
|
||||
|
||||
<Field size="fullWidth">
|
||||
<Label htmlFor="reward">
|
||||
{t('pages.loyalty-settings.reward', {
|
||||
currency:
|
||||
config?.currency ??
|
||||
t('pages.loyalty-settings.currency-placeholder'),
|
||||
currency: config?.currency ?? t('pages.loyalty-settings.currency-placeholder'),
|
||||
})}
|
||||
</Label>
|
||||
<FlexRow align="left" spacing={1}>
|
||||
|
@ -108,7 +101,7 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
disabled={!active || busy}
|
||||
required={true}
|
||||
onChange={(e) => {
|
||||
const intNum = parseInt(e.target.value, 10);
|
||||
const intNum = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
|
@ -146,9 +139,7 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
</Field>
|
||||
|
||||
<Field size="fullWidth">
|
||||
<Label htmlFor="bonus">
|
||||
{t('pages.loyalty-settings.bonus-points')}
|
||||
</Label>
|
||||
<Label htmlFor="bonus">{t('pages.loyalty-settings.bonus-points')}</Label>
|
||||
<InputBox
|
||||
type="number"
|
||||
id="bonus"
|
||||
|
@ -157,7 +148,7 @@ export default function LoyaltySettingsPage(): React.ReactElement {
|
|||
disabled={!active || busy}
|
||||
required={true}
|
||||
onChange={(e) => {
|
||||
const intNum = parseInt(e.target.value, 10);
|
||||
const intNum = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule, useUserPoints } from '~/lib/react';
|
||||
import { SortFunction } from '~/lib/types';
|
||||
import type { SortFunction } from '~/lib/types';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
|
||||
import { LoyaltyRedeem } from '~/store/api/types';
|
||||
import type { LoyaltyRedeem } from '~/store/api/types';
|
||||
import { DataTable } from '../../components/DataTable';
|
||||
import DialogContent from '../../components/DialogContent';
|
||||
import {
|
||||
|
@ -33,9 +34,7 @@ function RewardQueueRow({ data }: { data: LoyaltyRedeem & { date: Date } }) {
|
|||
|
||||
return (
|
||||
<TableRow key={`${data.when.toString()}${data.username}`}>
|
||||
<TableCell css={{ width: '22%', fontSize: '0.8rem' }}>
|
||||
{data.date.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell css={{ width: '22%', fontSize: '0.8rem' }}>{data.date.toLocaleString()}</TableCell>
|
||||
<TableCell css={{ width: '10%' }}>{data.username}</TableCell>
|
||||
<TableCell css={{ width: '18%' }}>{data.reward?.name}</TableCell>
|
||||
<TableCell css={{ width: '40%' }}>{data.request_text}</TableCell>
|
||||
|
@ -199,14 +198,9 @@ function UserList() {
|
|||
<>
|
||||
<Dialog
|
||||
open={givePointDialog.open}
|
||||
onOpenChange={(state) =>
|
||||
setGivePointDialog({ ...givePointDialog, open: state })
|
||||
}
|
||||
onOpenChange={(state) => setGivePointDialog({ ...givePointDialog, open: state })}
|
||||
>
|
||||
<DialogContent
|
||||
title={t('pages.loyalty-queue.give-points-dialog')}
|
||||
closeButton={true}
|
||||
>
|
||||
<DialogContent title={t('pages.loyalty-queue.give-points-dialog')} closeButton={true}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -223,9 +217,7 @@ function UserList() {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="d-username">
|
||||
{t('pages.loyalty-queue.username')}
|
||||
</Label>
|
||||
<Label htmlFor="d-username">{t('pages.loyalty-queue.username')}</Label>
|
||||
<InputBox
|
||||
id="d-username"
|
||||
required={true}
|
||||
|
@ -249,7 +241,7 @@ function UserList() {
|
|||
onChange={(e) =>
|
||||
setGivePointDialog({
|
||||
...givePointDialog,
|
||||
points: parseInt(e.target.value, 10),
|
||||
points: Number.parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -260,9 +252,7 @@ function UserList() {
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGivePointDialog({ ...givePointDialog, open: false })
|
||||
}
|
||||
onClick={() => setGivePointDialog({ ...givePointDialog, open: false })}
|
||||
>
|
||||
{t('form-actions.cancel')}
|
||||
</Button>
|
||||
|
@ -274,10 +264,7 @@ function UserList() {
|
|||
open={currentEntry !== null}
|
||||
onOpenChange={(state) => setCurrentEntry(state ? currentEntry : null)}
|
||||
>
|
||||
<DialogContent
|
||||
title={t('pages.loyalty-queue.modify-balance-dialog')}
|
||||
closeButton={true}
|
||||
>
|
||||
<DialogContent title={t('pages.loyalty-queue.modify-balance-dialog')} closeButton={true}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -294,14 +281,8 @@ function UserList() {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="d-username">
|
||||
{t('pages.loyalty-queue.username')}
|
||||
</Label>
|
||||
<InputBox
|
||||
disabled={true}
|
||||
id="d-username"
|
||||
value={currentEntry?.username ?? ''}
|
||||
/>
|
||||
<Label htmlFor="d-username">{t('pages.loyalty-queue.username')}</Label>
|
||||
<InputBox disabled={true} id="d-username" value={currentEntry?.username ?? ''} />
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="d-points" css={{ textTransform: 'capitalize' }}>
|
||||
|
@ -314,7 +295,7 @@ function UserList() {
|
|||
onChange={(e) =>
|
||||
setCurrentEntry({
|
||||
...currentEntry,
|
||||
points: parseInt(e.target.value, 10),
|
||||
points: Number.parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -332,11 +313,7 @@ function UserList() {
|
|||
</Dialog>
|
||||
<Field size="fullWidth" spacing="none">
|
||||
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setGivePointDialog({ open: true, user: '', points: 0 })
|
||||
}
|
||||
>
|
||||
<Button onClick={() => setGivePointDialog({ open: true, user: '', points: 0 })}>
|
||||
{t('pages.loyalty-queue.give-points-dialog')}
|
||||
</Button>
|
||||
<InputBox
|
||||
|
@ -399,12 +376,8 @@ export default function LoyaltyQueuePage(): React.ReactElement {
|
|||
</PageHeader>
|
||||
<TabContainer defaultValue="queue">
|
||||
<TabList>
|
||||
<TabButton value="queue">
|
||||
{t('pages.loyalty-queue.queue-tab')}
|
||||
</TabButton>
|
||||
<TabButton value="users">
|
||||
{t('pages.loyalty-queue.users-tab')}
|
||||
</TabButton>
|
||||
<TabButton value="queue">{t('pages.loyalty-queue.queue-tab')}</TabButton>
|
||||
<TabButton value="users">{t('pages.loyalty-queue.users-tab')}</TabButton>
|
||||
</TabList>
|
||||
<TabContent value="queue">
|
||||
<RewardQueue />
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useModule } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import { LoyaltyGoal } from '~/store/api/types';
|
||||
import type { LoyaltyGoal } from '~/store/api/types';
|
||||
import AlertContent from '../../../components/AlertContent';
|
||||
import DialogContent from '../../../components/DialogContent';
|
||||
import {
|
||||
|
@ -60,6 +60,7 @@ function GoalItem({
|
|||
<RewardIcon>
|
||||
{item.image && (
|
||||
<img
|
||||
aria-label={item.name}
|
||||
src={item.image}
|
||||
style={{ width: '32px', borderRadius: '0.25rem' }}
|
||||
/>
|
||||
|
@ -74,20 +75,10 @@ function GoalItem({
|
|||
</RewardCost>
|
||||
<RewardActions>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
{item.enabled
|
||||
? t('form-actions.disable')
|
||||
: t('form-actions.enable')}
|
||||
<Button styling="multi" size="small" onClick={() => (onToggle ? onToggle() : null)}>
|
||||
{item.enabled ? t('form-actions.disable') : t('form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onEdit ? onEdit() : null)}>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
|
@ -182,9 +173,7 @@ export function GoalsTab() {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="goal-id">
|
||||
{t('pages.loyalty-rewards.goal-id')}
|
||||
</Label>
|
||||
<Label htmlFor="goal-id">{t('pages.loyalty-rewards.goal-id')}</Label>
|
||||
<ControlledInputBox
|
||||
id="goal-id"
|
||||
type="text"
|
||||
|
@ -196,16 +185,10 @@ export function GoalsTab() {
|
|||
...dialogGoal,
|
||||
goal: {
|
||||
...dialogGoal?.goal,
|
||||
id:
|
||||
e.target.value
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '-') ?? '',
|
||||
id: e.target.value?.toLowerCase().replace(/[^a-z0-9]/gi, '-') ?? '',
|
||||
},
|
||||
});
|
||||
if (
|
||||
dialogGoal.new &&
|
||||
goals.find((r) => r.id === e.target.value)
|
||||
) {
|
||||
if (dialogGoal.new && goals.find((r) => r.id === e.target.value)) {
|
||||
(e.target as HTMLInputElement).setCustomValidity(
|
||||
t('pages.loyalty-rewards.id-already-in-use'),
|
||||
);
|
||||
|
@ -217,9 +200,7 @@ export function GoalsTab() {
|
|||
<FieldNote>{t('pages.loyalty-rewards.goal-id-hint')}</FieldNote>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="goal-name">
|
||||
{t('pages.loyalty-rewards.goal-name')}
|
||||
</Label>
|
||||
<Label htmlFor="goal-name">{t('pages.loyalty-rewards.goal-name')}</Label>
|
||||
<InputBox
|
||||
id="goal-name"
|
||||
type="text"
|
||||
|
@ -238,9 +219,7 @@ export function GoalsTab() {
|
|||
<FieldNote>{t('pages.loyalty-rewards.goal-name-hint')}</FieldNote>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="goal-icon">
|
||||
{t('pages.loyalty-rewards.goal-icon')}
|
||||
</Label>
|
||||
<Label htmlFor="goal-icon">{t('pages.loyalty-rewards.goal-icon')}</Label>
|
||||
<InputBox
|
||||
id="goal-icon"
|
||||
type="text"
|
||||
|
@ -257,9 +236,7 @@ export function GoalsTab() {
|
|||
/>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="goal-desc">
|
||||
{t('pages.loyalty-rewards.goal-desc')}
|
||||
</Label>
|
||||
<Label htmlFor="goal-desc">{t('pages.loyalty-rewards.goal-desc')}</Label>
|
||||
<Textarea
|
||||
id="goal-desc"
|
||||
value={dialogGoal?.goal?.description ?? ''}
|
||||
|
@ -277,9 +254,7 @@ export function GoalsTab() {
|
|||
</Textarea>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="goal-cost">
|
||||
{t('pages.loyalty-rewards.goal-cost')}
|
||||
</Label>
|
||||
<Label htmlFor="goal-cost">{t('pages.loyalty-rewards.goal-cost')}</Label>
|
||||
<InputBox
|
||||
id="goal-cost"
|
||||
type="number"
|
||||
|
@ -290,7 +265,7 @@ export function GoalsTab() {
|
|||
...dialogGoal,
|
||||
goal: {
|
||||
...dialogGoal?.goal,
|
||||
total: parseInt(e.target.value, 10),
|
||||
total: Number.parseInt(e.target.value, 10),
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
@ -298,14 +273,9 @@ export function GoalsTab() {
|
|||
</Field>
|
||||
<DialogActions>
|
||||
<Button variation="primary" type="submit">
|
||||
{dialogGoal.new
|
||||
? t('form-actions.create')
|
||||
: t('form-actions.edit')}
|
||||
{dialogGoal.new ? t('form-actions.create') : t('form-actions.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setDialogGoal({ ...dialogGoal, open: false })}
|
||||
>
|
||||
<Button type="button" onClick={() => setDialogGoal({ ...dialogGoal, open: false })}>
|
||||
{t('form-actions.cancel')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
@ -357,9 +327,7 @@ export function GoalsTab() {
|
|||
key={r.id}
|
||||
name={r.id}
|
||||
item={r}
|
||||
currency={(
|
||||
config?.currency || t('pages.loyalty-queue.points')
|
||||
).toLowerCase()}
|
||||
currency={(config?.currency || t('pages.loyalty-queue.points')).toLowerCase()}
|
||||
onEdit={() =>
|
||||
setDialogGoal({
|
||||
open: true,
|
||||
|
|
|
@ -23,12 +23,8 @@ export default function LoyaltyRewardsPage(): React.ReactElement {
|
|||
</PageHeader>
|
||||
<TabContainer defaultValue="rewards">
|
||||
<TabList>
|
||||
<TabButton value="rewards">
|
||||
{t('pages.loyalty-rewards.rewards-tab')}
|
||||
</TabButton>
|
||||
<TabButton value="goals">
|
||||
{t('pages.loyalty-rewards.goals-tab')}
|
||||
</TabButton>
|
||||
<TabButton value="rewards">{t('pages.loyalty-rewards.rewards-tab')}</TabButton>
|
||||
<TabButton value="goals">{t('pages.loyalty-rewards.goals-tab')}</TabButton>
|
||||
</TabList>
|
||||
<TabContent value="rewards">
|
||||
<RewardsTab />
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
|
||||
import React, { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import { LoyaltyReward } from '~/store/api/types';
|
||||
import type { LoyaltyReward } from '~/store/api/types';
|
||||
import AlertContent from '../../../components/AlertContent';
|
||||
import DialogContent from '../../../components/DialogContent';
|
||||
import Interval from '../../../components/forms/Interval';
|
||||
|
@ -63,6 +64,7 @@ function RewardItem({
|
|||
<RewardIcon>
|
||||
{item.image && (
|
||||
<img
|
||||
alt={item.name}
|
||||
src={item.image}
|
||||
style={{ width: '32px', borderRadius: '0.25rem' }}
|
||||
/>
|
||||
|
@ -76,20 +78,10 @@ function RewardItem({
|
|||
</RewardCost>
|
||||
<RewardActions>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
{item.enabled
|
||||
? t('form-actions.disable')
|
||||
: t('form-actions.enable')}
|
||||
<Button styling="multi" size="small" onClick={() => (onToggle ? onToggle() : null)}>
|
||||
{item.enabled ? t('form-actions.disable') : t('form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onEdit ? onEdit() : null)}>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
|
@ -160,9 +152,7 @@ export function RewardsTab() {
|
|||
<>
|
||||
<Dialog
|
||||
open={dialogReward.open}
|
||||
onOpenChange={(state) =>
|
||||
setDialogReward({ ...dialogReward, open: state })
|
||||
}
|
||||
onOpenChange={(state) => setDialogReward({ ...dialogReward, open: state })}
|
||||
>
|
||||
<DialogContent
|
||||
title={
|
||||
|
@ -194,9 +184,7 @@ export function RewardsTab() {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-id">
|
||||
{t('pages.loyalty-rewards.reward-id')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-id">{t('pages.loyalty-rewards.reward-id')}</Label>
|
||||
<ControlledInputBox
|
||||
id="reward-id"
|
||||
type="text"
|
||||
|
@ -212,19 +200,11 @@ export function RewardsTab() {
|
|||
...dialogReward,
|
||||
reward: {
|
||||
...dialogReward?.reward,
|
||||
id:
|
||||
e.target.value
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '-') ?? '',
|
||||
id: e.target.value?.toLowerCase().replace(/[^a-z0-9]/gi, '-') ?? '',
|
||||
},
|
||||
});
|
||||
if (
|
||||
dialogReward.new &&
|
||||
rewards.find((r) => r.id === e.target.value)
|
||||
) {
|
||||
e.target.setCustomValidity(
|
||||
t('pages.loyalty-rewards.id-already-in-use'),
|
||||
);
|
||||
if (dialogReward.new && rewards.find((r) => r.id === e.target.value)) {
|
||||
e.target.setCustomValidity(t('pages.loyalty-rewards.id-already-in-use'));
|
||||
} else {
|
||||
e.target.setCustomValidity('');
|
||||
}
|
||||
|
@ -233,9 +213,7 @@ export function RewardsTab() {
|
|||
<FieldNote>{t('pages.loyalty-rewards.reward-id-hint')}</FieldNote>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-name">
|
||||
{t('pages.loyalty-rewards.reward-name')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-name">{t('pages.loyalty-rewards.reward-name')}</Label>
|
||||
<InputBox
|
||||
id="reward-name"
|
||||
type="text"
|
||||
|
@ -251,14 +229,10 @@ export function RewardsTab() {
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<FieldNote>
|
||||
{t('pages.loyalty-rewards.reward-name-hint')}
|
||||
</FieldNote>
|
||||
<FieldNote>{t('pages.loyalty-rewards.reward-name-hint')}</FieldNote>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-icon">
|
||||
{t('pages.loyalty-rewards.reward-icon')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-icon">{t('pages.loyalty-rewards.reward-icon')}</Label>
|
||||
<InputBox
|
||||
id="reward-icon"
|
||||
type="text"
|
||||
|
@ -275,9 +249,7 @@ export function RewardsTab() {
|
|||
/>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-desc">
|
||||
{t('pages.loyalty-rewards.reward-desc')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-desc">{t('pages.loyalty-rewards.reward-desc')}</Label>
|
||||
<Textarea
|
||||
id="reward-desc"
|
||||
value={dialogReward?.reward?.description ?? ''}
|
||||
|
@ -295,9 +267,7 @@ export function RewardsTab() {
|
|||
</Textarea>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-cost">
|
||||
{t('pages.loyalty-rewards.reward-cost')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-cost">{t('pages.loyalty-rewards.reward-cost')}</Label>
|
||||
<InputBox
|
||||
id="reward-cost"
|
||||
type="number"
|
||||
|
@ -308,16 +278,14 @@ export function RewardsTab() {
|
|||
...dialogReward,
|
||||
reward: {
|
||||
...dialogReward?.reward,
|
||||
price: parseInt(e.target.value, 10),
|
||||
price: Number.parseInt(e.target.value, 10),
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="reward-cooldown">
|
||||
{t('pages.loyalty-rewards.reward-cooldown')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-cooldown">{t('pages.loyalty-rewards.reward-cooldown')}</Label>
|
||||
<FlexRow align="left">
|
||||
<Interval
|
||||
value={dialogReward?.reward?.cooldown ?? 0}
|
||||
|
@ -346,13 +314,9 @@ export function RewardsTab() {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
{requiredInfo.enabled && <CheckIcon />}
|
||||
</CheckboxIndicator>
|
||||
<CheckboxIndicator>{requiredInfo.enabled && <CheckIcon />}</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
<Label htmlFor="reward-details">
|
||||
{t('pages.loyalty-rewards.reward-details')}
|
||||
</Label>
|
||||
<Label htmlFor="reward-details">{t('pages.loyalty-rewards.reward-details')}</Label>
|
||||
</FlexRow>
|
||||
<InputBox
|
||||
id="reward-details-text"
|
||||
|
@ -360,9 +324,7 @@ export function RewardsTab() {
|
|||
disabled={!requiredInfo.enabled}
|
||||
required={requiredInfo.enabled}
|
||||
value={dialogReward?.reward?.required_info ?? ''}
|
||||
placeholder={t(
|
||||
'pages.loyalty-rewards.reward-details-placeholder',
|
||||
)}
|
||||
placeholder={t('pages.loyalty-rewards.reward-details-placeholder')}
|
||||
onChange={(e) => {
|
||||
setRequiredInfo({ ...requiredInfo, text: e.target.value });
|
||||
}}
|
||||
|
@ -370,15 +332,11 @@ export function RewardsTab() {
|
|||
</Field>
|
||||
<DialogActions>
|
||||
<Button variation="primary" type="submit">
|
||||
{dialogReward.new
|
||||
? t('form-actions.create')
|
||||
: t('form-actions.edit')}
|
||||
{dialogReward.new ? t('form-actions.create') : t('form-actions.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDialogReward({ ...dialogReward, open: false })
|
||||
}
|
||||
onClick={() => setDialogReward({ ...dialogReward, open: false })}
|
||||
>
|
||||
{t('form-actions.cancel')}
|
||||
</Button>
|
||||
|
@ -434,9 +392,7 @@ export function RewardsTab() {
|
|||
key={r.id}
|
||||
name={r.id}
|
||||
item={r}
|
||||
currency={(
|
||||
config?.currency || t('pages.loyalty-queue.points')
|
||||
).toLowerCase()}
|
||||
currency={(config?.currency || t('pages.loyalty-queue.points')).toLowerCase()}
|
||||
onEdit={() =>
|
||||
setDialogReward({
|
||||
open: true,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '~/store';
|
||||
import { useAppSelector } from '~/store';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
|
@ -41,7 +41,7 @@ export default function DebugPage(): React.ReactElement {
|
|||
const [writeKey, setWriteKey] = useState('');
|
||||
const [writeValue, setWriteValue] = useState('');
|
||||
const [writeErrorMsg, setWriteErrorMsg] = useState<string>(null);
|
||||
const api = useSelector((state: RootState) => state.api.client);
|
||||
const api = useAppSelector((state) => state.api.client);
|
||||
|
||||
const performRead = async () => {
|
||||
const value = await api.getKey(readKey);
|
||||
|
@ -71,9 +71,7 @@ export default function DebugPage(): React.ReactElement {
|
|||
return (
|
||||
<Disclaimer>
|
||||
<DisclaimerTitle>{t('pages.debug.disclaimer-header')}</DisclaimerTitle>
|
||||
<DisclaimerParagraph>
|
||||
{t('pages.debug.big-ass-warning')}
|
||||
</DisclaimerParagraph>
|
||||
<DisclaimerParagraph>{t('pages.debug.big-ass-warning')}</DisclaimerParagraph>
|
||||
<Button variation="primary" onClick={() => setWarningDismissed(true)}>
|
||||
{t('pages.debug.dismiss-warning')}
|
||||
</Button>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Editor, { Monaco, useMonaco } from '@monaco-editor/react';
|
||||
import Editor, { type Monaco, useMonaco } from '@monaco-editor/react';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InfoCircledIcon,
|
||||
|
@ -6,7 +6,8 @@ import {
|
|||
PilcrowIcon,
|
||||
PlusIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { blankTemplate } from '~/lib/extensions/extension';
|
||||
import { kilovoltDefinition } from '~/lib/extensions/helpers';
|
||||
|
@ -17,7 +18,7 @@ import * as HoverCard from '@radix-ui/react-hover-card';
|
|||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import extensionsReducer, {
|
||||
currentFile,
|
||||
ExtensionEntry,
|
||||
type ExtensionEntry,
|
||||
isUnsaved,
|
||||
removeExtension,
|
||||
renameExtension,
|
||||
|
@ -149,14 +150,11 @@ function ExtensionListItem(props: ExtensionListItemProps) {
|
|||
const { t } = useTranslation();
|
||||
const metadata = parseExtensionMetadata(props.entry.source);
|
||||
const version = useAppSelector((state) => state.server.version?.release);
|
||||
const isDev = version && version.startsWith('v0.0.0');
|
||||
const showIncompatibleWarning =
|
||||
!isDev && version && version < `v${metadata.apiversion}`;
|
||||
const isDev = version?.startsWith('v0.0.0');
|
||||
const showIncompatibleWarning = !isDev && version && version < `v${metadata.apiversion}`;
|
||||
|
||||
return (
|
||||
<ExtensionRow
|
||||
status={props.enabled && isRunning(props.status) ? 'enabled' : 'disabled'}
|
||||
>
|
||||
<ExtensionRow status={props.enabled && isRunning(props.status) ? 'enabled' : 'disabled'}>
|
||||
<FlexRow>
|
||||
<ExtensionName>
|
||||
{metadata ? (
|
||||
|
@ -174,9 +172,7 @@ function ExtensionListItem(props: ExtensionListItemProps) {
|
|||
{metadata.version ? `v${metadata.version}` : null}
|
||||
{metadata.author ? ` by ${metadata.author}` : null}
|
||||
</div>
|
||||
{metadata.description ? (
|
||||
<small>{metadata.description}</small>
|
||||
) : null}
|
||||
{metadata.description ? <small>{metadata.description}</small> : null}
|
||||
</ExtensionInfoCard>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
|
@ -232,25 +228,13 @@ function ExtensionListItem(props: ExtensionListItemProps) {
|
|||
</ExtensionName>
|
||||
<ExtensionActions>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => props.onToggleEnable()}
|
||||
>
|
||||
{props.entry.options.enabled
|
||||
? t('form-actions.disable')
|
||||
: t('form-actions.enable')}
|
||||
<Button styling="multi" size="small" onClick={() => props.onToggleEnable()}>
|
||||
{props.entry.options.enabled ? t('form-actions.disable') : t('form-actions.enable')}
|
||||
</Button>
|
||||
{props.enabled ? (
|
||||
<>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => props.onToggleStatus()}
|
||||
>
|
||||
{isRunning(props.status)
|
||||
? t('form-actions.stop')
|
||||
: t('form-actions.start')}
|
||||
<Button styling="multi" size="small" onClick={() => props.onToggleStatus()}>
|
||||
{isRunning(props.status) ? t('form-actions.stop') : t('form-actions.start')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
@ -423,16 +407,11 @@ function ExtensionEditor() {
|
|||
height: 'calc(100vh - 40px)',
|
||||
}}
|
||||
>
|
||||
<FlexRow
|
||||
css={{ alignItems: 'stretch', borderBottom: '1px solid $gray5' }}
|
||||
align="left"
|
||||
>
|
||||
<FlexRow css={{ alignItems: 'stretch', borderBottom: '1px solid $gray5' }} align="left">
|
||||
<EditorDropdown
|
||||
value={extensions.editorCurrentFile}
|
||||
onChange={(ev) => {
|
||||
void dispatch(
|
||||
extensionsReducer.actions.editorSelectedFile(ev.target.value),
|
||||
);
|
||||
void dispatch(extensionsReducer.actions.editorSelectedFile(ev.target.value));
|
||||
}}
|
||||
css={{ flex: '1' }}
|
||||
>
|
||||
|
@ -453,9 +432,7 @@ function ExtensionEditor() {
|
|||
<EditorButton
|
||||
size="small"
|
||||
title={t('pages.extensions.rename')}
|
||||
onClick={() =>
|
||||
setDialogRename({ open: true, name: extensions.editorCurrentFile })
|
||||
}
|
||||
onClick={() => setDialogRename({ open: true, name: extensions.editorCurrentFile })}
|
||||
>
|
||||
<InputIcon />
|
||||
</EditorButton>
|
||||
|
@ -507,9 +484,7 @@ function ExtensionEditor() {
|
|||
/>
|
||||
<Dialog
|
||||
open={dialogRename.open}
|
||||
onOpenChange={(state) =>
|
||||
setDialogRename({ ...dialogRename, open: state })
|
||||
}
|
||||
onOpenChange={(state) => setDialogRename({ ...dialogRename, open: state })}
|
||||
>
|
||||
<DialogContent
|
||||
title={t('pages.extensions.rename-dialog', {
|
||||
|
@ -538,9 +513,7 @@ function ExtensionEditor() {
|
|||
}}
|
||||
>
|
||||
<Field size="fullWidth" spacing="narrow">
|
||||
<Label htmlFor="renamed">
|
||||
{t('pages.extensions.rename-new-name')}
|
||||
</Label>
|
||||
<Label htmlFor="renamed">{t('pages.extensions.rename-new-name')}</Label>
|
||||
<ControlledInputBox
|
||||
id="renamed"
|
||||
type="text"
|
||||
|
@ -551,11 +524,7 @@ function ExtensionEditor() {
|
|||
...dialogRename,
|
||||
name: e.target.value,
|
||||
});
|
||||
if (
|
||||
Object.values(extensions.installed).find(
|
||||
(r) => r.name === e.target.value,
|
||||
)
|
||||
) {
|
||||
if (Object.values(extensions.installed).find((r) => r.name === e.target.value)) {
|
||||
(e.target as HTMLInputElement).setCustomValidity(
|
||||
t('pages.extensions.name-already-in-use'),
|
||||
);
|
||||
|
@ -571,9 +540,7 @@ function ExtensionEditor() {
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDialogRename({ ...dialogRename, open: false })
|
||||
}
|
||||
onClick={() => setDialogRename({ ...dialogRename, open: false })}
|
||||
>
|
||||
{t('form-actions.cancel')}
|
||||
</Button>
|
||||
|
@ -597,10 +564,7 @@ export default function ExtensionsPage(): React.ReactElement {
|
|||
let defaultName = '';
|
||||
do {
|
||||
defaultName = slug();
|
||||
} while (
|
||||
defaultName in extensions.installed ||
|
||||
defaultName in extensions.unsaved
|
||||
);
|
||||
} while (defaultName in extensions.installed || defaultName in extensions.unsaved);
|
||||
|
||||
// Add as draft
|
||||
dispatch(
|
||||
|
@ -623,29 +587,20 @@ export default function ExtensionsPage(): React.ReactElement {
|
|||
};
|
||||
|
||||
if (!extensions.ready) {
|
||||
const theme = getTheme(
|
||||
uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark',
|
||||
);
|
||||
const theme = getTheme(uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark');
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>{t('pages.extensions.title')}</PageTitle>
|
||||
</PageHeader>
|
||||
<Loading
|
||||
theme={theme}
|
||||
size="fill"
|
||||
message={t('pages.extensions.loading')}
|
||||
/>
|
||||
<Loading theme={theme} size="fill" message={t('pages.extensions.loading')} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabContainer
|
||||
value={currentTab}
|
||||
onValueChange={(newval) => setCurrentTab(newval)}
|
||||
>
|
||||
<TabContainer value={currentTab} onValueChange={(newval) => setCurrentTab(newval)}>
|
||||
<TabList>
|
||||
<TabButton value="list">{t('pages.extensions.tab-manage')}</TabButton>
|
||||
<TabButton value="editor" disabled={!extensions.editorCurrentFile}>
|
||||
|
@ -653,10 +608,7 @@ export default function ExtensionsPage(): React.ReactElement {
|
|||
</TabButton>
|
||||
</TabList>
|
||||
<TabContent css={{ paddingTop: '1rem' }} value="list">
|
||||
<ExtensionList
|
||||
onNew={() => newClicked()}
|
||||
onEdit={(name) => editClicked(name)}
|
||||
/>
|
||||
<ExtensionList onNew={() => newClicked()} onEdit={(name) => editClicked(name)} />
|
||||
</TabContent>
|
||||
<TabContent css={{ paddingTop: '0' }} value="editor">
|
||||
<ExtensionEditor />
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule, useStatus } from '~/lib/react';
|
||||
import { useModule, useTimedStatus } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
import AlertContent from '../../components/AlertContent';
|
||||
|
@ -19,14 +20,11 @@ import {
|
|||
import { Alert } from '../../theme/alert';
|
||||
|
||||
export default function ServerSettingsPage(): React.ReactElement {
|
||||
const [serverConfig, setServerConfig, loadStatus] = useModule(
|
||||
modules.httpConfig,
|
||||
);
|
||||
const [serverConfig, setServerConfig, loadStatus] = useModule(modules.httpConfig);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useStatus(loadStatus.save);
|
||||
const busy =
|
||||
loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
|
||||
const status = useTimedStatus(loadStatus.save);
|
||||
const busy = loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
|
||||
const [revealKVPassword, setRevealKVPassword] = useState(false);
|
||||
const [showKilovoltWarning, setShowKilovoltWarning] = useState(false);
|
||||
|
||||
|
@ -105,9 +103,7 @@ export default function ServerSettingsPage(): React.ReactElement {
|
|||
}),
|
||||
)
|
||||
}
|
||||
value={
|
||||
serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''
|
||||
}
|
||||
value={serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''}
|
||||
/>
|
||||
<FieldNote>
|
||||
{t('pages.http.static-help', {
|
||||
|
@ -116,11 +112,7 @@ export default function ServerSettingsPage(): React.ReactElement {
|
|||
</FieldNote>
|
||||
</Field>
|
||||
<SaveButton type="submit" status={status} />
|
||||
<Alert
|
||||
defaultOpen={false}
|
||||
open={showKilovoltWarning}
|
||||
onOpenChange={setShowKilovoltWarning}
|
||||
>
|
||||
<Alert defaultOpen={false} open={showKilovoltWarning} onOpenChange={setShowKilovoltWarning}>
|
||||
<AlertContent
|
||||
variation="danger"
|
||||
title={t('pages.http.kv-auth-warning.header')}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { keyframes } from '@stitches/react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
@ -131,11 +132,7 @@ export default function StrimertulPage(): React.ReactElement {
|
|||
t={t}
|
||||
i18nKey="pages.strimertul.credits-renko"
|
||||
components={{
|
||||
artist: (
|
||||
<BrowserLink href="https://twitter.com/Sonic__Chan">
|
||||
Sonic_Chan
|
||||
</BrowserLink>
|
||||
),
|
||||
artist: <BrowserLink href="https://twitter.com/Sonic__Chan">Sonic_Chan</BrowserLink>,
|
||||
}}
|
||||
/>
|
||||
</SectionParagraph>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '~/lib/react';
|
||||
import { languages } from '~/locale/languages';
|
||||
|
@ -20,10 +20,7 @@ const PartialWarning = styled('small', {
|
|||
color: '$yellow11',
|
||||
});
|
||||
|
||||
const maxKeys = languages.reduce(
|
||||
(current, it) => Math.max(current, it.keys),
|
||||
0,
|
||||
);
|
||||
const maxKeys = languages.reduce((current, it) => Math.max(current, it.keys), 0);
|
||||
|
||||
export default function UISettingsPage(): React.ReactElement {
|
||||
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
||||
|
@ -53,8 +50,7 @@ export default function UISettingsPage(): React.ReactElement {
|
|||
{lang.keys < maxKeys ? (
|
||||
<PartialWarning>
|
||||
{t('pages.uiconfig.partial-translation')} (
|
||||
{((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/
|
||||
{maxKeys})
|
||||
{((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/{maxKeys})
|
||||
</PartialWarning>
|
||||
) : null}
|
||||
</span>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { useModule, useStatus } from '~/lib/react';
|
||||
import { useModule, useTimedStatus } from '~/lib/react';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import MultiInput from '../../components/forms/MultiInput';
|
||||
|
@ -26,7 +26,7 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchChatAlerts);
|
||||
const status = useStatus(loadStatus.save);
|
||||
const status = useTimedStatus(loadStatus.save);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
@ -42,19 +42,11 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
</PageHeader>
|
||||
<TabContainer defaultValue="follow">
|
||||
<TabList>
|
||||
<TabButton value="follow">
|
||||
{t('pages.alerts.events.follow')}
|
||||
</TabButton>
|
||||
<TabButton value="sub">
|
||||
{t('pages.alerts.events.subscription')}
|
||||
</TabButton>
|
||||
<TabButton value="gift">
|
||||
{t('pages.alerts.events.gift-sub')}
|
||||
</TabButton>
|
||||
<TabButton value="follow">{t('pages.alerts.events.follow')}</TabButton>
|
||||
<TabButton value="sub">{t('pages.alerts.events.subscription')}</TabButton>
|
||||
<TabButton value="gift">{t('pages.alerts.events.gift-sub')}</TabButton>
|
||||
<TabButton value="raid">{t('pages.alerts.events.raid')}</TabButton>
|
||||
<TabButton value="cheer">
|
||||
{t('pages.alerts.events.cheer')}
|
||||
</TabButton>
|
||||
<TabButton value="cheer">{t('pages.alerts.events.cheer')}</TabButton>
|
||||
</TabList>
|
||||
<TabContent value="follow">
|
||||
<Field size="fullWidth">
|
||||
|
@ -74,14 +66,10 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
}}
|
||||
id="follow-enabled"
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
{alerts?.follow?.enabled && <CheckIcon />}
|
||||
</CheckboxIndicator>
|
||||
<CheckboxIndicator>{alerts?.follow?.enabled && <CheckIcon />}</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
|
||||
<Label htmlFor="follow-enabled">
|
||||
{t('pages.alerts.follow-enable')}
|
||||
</Label>
|
||||
<Label htmlFor="follow-enabled">{t('pages.alerts.follow-enable')}</Label>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
<Field size="fullWidth">
|
||||
|
@ -172,9 +160,7 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
|
||||
<Label htmlFor="gift_sub-enabled">
|
||||
{t('pages.alerts.gift_sub-enable')}
|
||||
</Label>
|
||||
<Label htmlFor="gift_sub-enabled">{t('pages.alerts.gift_sub-enable')}</Label>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
<Field size="fullWidth">
|
||||
|
@ -214,14 +200,10 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
}}
|
||||
id="raid-enabled"
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
{alerts?.raid?.enabled && <CheckIcon />}
|
||||
</CheckboxIndicator>
|
||||
<CheckboxIndicator>{alerts?.raid?.enabled && <CheckIcon />}</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
|
||||
<Label htmlFor="raid-enabled">
|
||||
{t('pages.alerts.raid-enable')}
|
||||
</Label>
|
||||
<Label htmlFor="raid-enabled">{t('pages.alerts.raid-enable')}</Label>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
<Field size="fullWidth">
|
||||
|
@ -261,14 +243,10 @@ export default function ChatAlertsPage(): React.ReactElement {
|
|||
}}
|
||||
id="raid-enabled"
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
{alerts?.cheer?.enabled && <CheckIcon />}
|
||||
</CheckboxIndicator>
|
||||
<CheckboxIndicator>{alerts?.cheer?.enabled && <CheckIcon />}</CheckboxIndicator>
|
||||
</Checkbox>
|
||||
|
||||
<Label htmlFor="cheer-enabled">
|
||||
{t('pages.alerts.cheer-enable')}
|
||||
</Label>
|
||||
<Label htmlFor="cheer-enabled">{t('pages.alerts.cheer-enable')}</Label>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
<Field size="fullWidth">
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import {
|
||||
accessLevels,
|
||||
AccessLevelType,
|
||||
ReplyType,
|
||||
TwitchChatCustomCommand,
|
||||
type AccessLevelType,
|
||||
type ReplyType,
|
||||
type TwitchChatCustomCommand,
|
||||
} from '~/store/api/types';
|
||||
import { TestCommandTemplate } from '@wailsapp/go/main/App';
|
||||
import AlertContent from '../../components/AlertContent';
|
||||
|
@ -155,9 +156,7 @@ function CommandItem({
|
|||
return (
|
||||
<CommandItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
<CommandHeader>
|
||||
<CommandName status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
{name}
|
||||
</CommandName>
|
||||
<CommandName status={item.enabled ? 'enabled' : 'disabled'}>{name}</CommandName>
|
||||
<CommandDescription>{item.description}</CommandDescription>
|
||||
<CommandActions>
|
||||
{item.access_level !== 'everyone' && (
|
||||
|
@ -167,18 +166,10 @@ function CommandItem({
|
|||
</ACLIndicator>
|
||||
)}
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onToggle ? onToggle() : null)}>
|
||||
{t(item.enabled ? 'form-actions.disable' : 'form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onEdit ? onEdit() : null)}>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
|
@ -210,9 +201,7 @@ function CommandItem({
|
|||
);
|
||||
}
|
||||
|
||||
type DialogPrompt =
|
||||
| { kind: 'new' }
|
||||
| { kind: 'edit'; name: string; item: TwitchChatCustomCommand };
|
||||
type DialogPrompt = { kind: 'new' } | { kind: 'edit'; name: string; item: TwitchChatCustomCommand };
|
||||
|
||||
function CommandDialog({
|
||||
kind,
|
||||
|
@ -228,23 +217,16 @@ function CommandDialog({
|
|||
const [commands] = useModule(modules.twitchChatCommands);
|
||||
const [commandName, setCommandName] = useState(name ?? '');
|
||||
const [description, setDescription] = useState(item?.description ?? '');
|
||||
const [responseType, setResponseType] = useState(
|
||||
item?.response_type ?? 'chat',
|
||||
);
|
||||
const [responseType, setResponseType] = useState(item?.response_type ?? 'chat');
|
||||
const [response, setResponse] = useState(item?.response ?? '');
|
||||
const responseRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [accessLevel, setAccessLevel] = useState(
|
||||
item?.access_level ?? 'everyone',
|
||||
);
|
||||
const [accessLevel, setAccessLevel] = useState(item?.access_level ?? 'everyone');
|
||||
const [responseError, setResponseError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const replyTypes: ReplyType[] = ['chat', 'reply', 'whisper', 'announce'];
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
title={t(`pages.botcommands.command-header-${kind}`)}
|
||||
closeButton={true}
|
||||
>
|
||||
<DialogContent title={t(`pages.botcommands.command-header-${kind}`)} closeButton={true}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (!(e.target as HTMLFormElement).checkValidity()) {
|
||||
|
@ -265,17 +247,13 @@ function CommandDialog({
|
|||
}
|
||||
} catch (error: unknown) {
|
||||
setResponseError(error as string);
|
||||
responseRef.current?.setCustomValidity(
|
||||
t('pages.botcommands.command-invalid-format'),
|
||||
);
|
||||
responseRef.current?.setCustomValidity(t('pages.botcommands.command-invalid-format'));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="command-name">
|
||||
{t('pages.botcommands.command-name')}
|
||||
</Label>
|
||||
<Label htmlFor="command-name">{t('pages.botcommands.command-name')}</Label>
|
||||
<InputBox
|
||||
id="command-name"
|
||||
value={commandName}
|
||||
|
@ -295,9 +273,7 @@ function CommandDialog({
|
|||
/>
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="command-description">
|
||||
{t('pages.botcommands.command-desc')}
|
||||
</Label>
|
||||
<Label htmlFor="command-description">{t('pages.botcommands.command-desc')}</Label>
|
||||
<InputBox
|
||||
id="command-description"
|
||||
value={description}
|
||||
|
@ -347,9 +323,7 @@ function CommandDialog({
|
|||
)}
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="command-acl">
|
||||
{t('pages.botcommands.command-acl')}
|
||||
</Label>
|
||||
<Label htmlFor="command-acl">{t('pages.botcommands.command-acl')}</Label>
|
||||
<ComboBox
|
||||
id="command-acl"
|
||||
value={accessLevel}
|
||||
|
@ -442,10 +416,7 @@ export default function TwitchChatCommandsPage(): React.ReactElement {
|
|||
</PageHeader>
|
||||
|
||||
<FlexRow spacing="1" align="left">
|
||||
<Button
|
||||
variation="primary"
|
||||
onClick={() => setActiveDialog({ kind: 'new' })}
|
||||
>
|
||||
<Button variation="primary" onClick={() => setActiveDialog({ kind: 'new' })}>
|
||||
<PlusIcon /> {t('pages.botcommands.add-button')}
|
||||
</Button>
|
||||
|
||||
|
@ -496,10 +467,7 @@ export default function TwitchChatCommandsPage(): React.ReactElement {
|
|||
}}
|
||||
>
|
||||
{activeDialog && (
|
||||
<CommandDialog
|
||||
{...activeDialog}
|
||||
onSubmit={(name, data) => setCommand(name, data)}
|
||||
/>
|
||||
<CommandDialog {...activeDialog} onSubmit={(name, data) => setCommand(name, data)} />
|
||||
)}
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import { TwitchChatTimer } from '~/store/api/types';
|
||||
import type { TwitchChatTimer } from '~/store/api/types';
|
||||
import AlertContent from '../../components/AlertContent';
|
||||
import DialogContent from '../../components/DialogContent';
|
||||
import Interval from '../../components/forms/Interval';
|
||||
|
@ -114,21 +115,13 @@ interface TimerItemProps {
|
|||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function TimerItem({
|
||||
name,
|
||||
item,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TimerItemProps): React.ReactElement {
|
||||
function TimerItem({ name, item, onToggle, onEdit, onDelete }: TimerItemProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TimerItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
<TimerHeader>
|
||||
<TimerName status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
{name}
|
||||
</TimerName>
|
||||
<TimerName status={item.enabled ? 'enabled' : 'disabled'}>{name}</TimerName>
|
||||
<TimerDescription>
|
||||
(
|
||||
{t('pages.bottimers.timer-parameters', {
|
||||
|
@ -140,18 +133,10 @@ function TimerItem({
|
|||
</TimerDescription>
|
||||
<TimerActions>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onToggle ? onToggle() : null)}>
|
||||
{t(item.enabled ? 'form-actions.disable' : 'form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
<Button styling="multi" size="small" onClick={() => (onEdit ? onEdit() : null)}>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
|
@ -180,9 +165,7 @@ function TimerItem({
|
|||
);
|
||||
}
|
||||
|
||||
type DialogPrompt =
|
||||
| { kind: 'new' }
|
||||
| { kind: 'edit'; name: string; item: TwitchChatTimer };
|
||||
type DialogPrompt = { kind: 'new' } | { kind: 'edit'; name: string; item: TwitchChatTimer };
|
||||
|
||||
function TimerDialog({
|
||||
kind,
|
||||
|
@ -199,16 +182,11 @@ function TimerDialog({
|
|||
const [timerName, setName] = useState(name ?? '');
|
||||
const [messages, setMessages] = useState(item?.messages ?? ['']);
|
||||
const [minDelay, setMinDelay] = useState(item?.minimum_delay ?? 300);
|
||||
const [minActivity, setMinActivity] = useState(
|
||||
item?.minimum_chat_activity ?? 5,
|
||||
);
|
||||
const [minActivity, setMinActivity] = useState(item?.minimum_chat_activity ?? 5);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
title={t(`pages.bottimers.timer-header-${kind}`)}
|
||||
closeButton={true}
|
||||
>
|
||||
<DialogContent title={t(`pages.bottimers.timer-header-${kind}`)} closeButton={true}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (!(e.target as HTMLFormElement).checkValidity()) {
|
||||
|
@ -233,10 +211,7 @@ function TimerDialog({
|
|||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
// If timer name is different but matches another defined timer, set as invalid
|
||||
if (
|
||||
e.target.value !== name &&
|
||||
e.target.value in timerConfig.timers
|
||||
) {
|
||||
if (e.target.value !== name && e.target.value in timerConfig.timers) {
|
||||
(e.target as HTMLInputElement).setCustomValidity(
|
||||
t('pages.bottimers.name-already-in-use'),
|
||||
);
|
||||
|
@ -249,9 +224,7 @@ function TimerDialog({
|
|||
/>
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="timer-interval">
|
||||
{t('pages.bottimers.timer-interval')}
|
||||
</Label>
|
||||
<Label htmlFor="timer-interval">{t('pages.bottimers.timer-interval')}</Label>
|
||||
<FlexRow align="left">
|
||||
<Interval
|
||||
id="timer-interval"
|
||||
|
@ -265,9 +238,7 @@ function TimerDialog({
|
|||
</FlexRow>
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="timer-activity">
|
||||
{t('pages.bottimers.timer-activity')}
|
||||
</Label>
|
||||
<Label htmlFor="timer-activity">{t('pages.bottimers.timer-activity')}</Label>
|
||||
<FlexRow align="left" spacing={1}>
|
||||
<InputBox
|
||||
id="timer-activity"
|
||||
|
@ -278,7 +249,7 @@ function TimerDialog({
|
|||
}}
|
||||
required={true}
|
||||
onChange={(ev) => {
|
||||
const intNum = parseInt(ev.target.value, 10);
|
||||
const intNum = Number.parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
|
@ -386,10 +357,7 @@ export default function TwitchChatTimersPage(): React.ReactElement {
|
|||
</PageHeader>
|
||||
|
||||
<FlexRow spacing="1" align="left">
|
||||
<Button
|
||||
variation="primary"
|
||||
onClick={() => setActiveDialog({ kind: 'new' })}
|
||||
>
|
||||
<Button variation="primary" onClick={() => setActiveDialog({ kind: 'new' })}>
|
||||
<PlusIcon /> {t('pages.bottimers.add-button')}
|
||||
</Button>
|
||||
|
||||
|
@ -436,10 +404,7 @@ export default function TwitchChatTimersPage(): React.ReactElement {
|
|||
}}
|
||||
>
|
||||
{activeDialog && (
|
||||
<TimerDialog
|
||||
{...activeDialog}
|
||||
onSubmit={(name, data) => setTimer(name, data)}
|
||||
/>
|
||||
<TimerDialog {...activeDialog} onSubmit={(name, data) => setTimer(name, data)} />
|
||||
)}
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
|
@ -59,15 +59,9 @@ export default function TwitchSettingsPage(): React.ReactElement {
|
|||
<div style={{ display: active ? '' : 'none' }}>
|
||||
<TabContainer defaultValue="api-config">
|
||||
<TabList>
|
||||
<TabButton value="api-config">
|
||||
{t('pages.twitch-settings.api-configuration')}
|
||||
</TabButton>
|
||||
<TabButton value="eventsub">
|
||||
{t('pages.twitch-settings.eventsub')}
|
||||
</TabButton>
|
||||
<TabButton value="chat-settings">
|
||||
{t('pages.twitch-settings.chat-settings')}
|
||||
</TabButton>
|
||||
<TabButton value="api-config">{t('pages.twitch-settings.api-configuration')}</TabButton>
|
||||
<TabButton value="eventsub">{t('pages.twitch-settings.eventsub')}</TabButton>
|
||||
<TabButton value="chat-settings">{t('pages.twitch-settings.chat-settings')}</TabButton>
|
||||
</TabList>
|
||||
<TabContent value="api-config">
|
||||
<TwitchAPISettings />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useModule, useStatus } from '~/lib/react';
|
||||
import { useModule, useTimedStatus } from '~/lib/react';
|
||||
import { useAppDispatch } from '~/store';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
import { checkTwitchKeys } from '~/lib/twitch';
|
||||
|
@ -43,10 +43,8 @@ type TestResult = { open: boolean; error?: Error };
|
|||
export default function TwitchAPISettings() {
|
||||
const { t } = useTranslation();
|
||||
const [httpConfig] = useModule(modules.httpConfig);
|
||||
const [twitchConfig, setTwitchConfig, loadStatus] = useModule(
|
||||
modules.twitchConfig,
|
||||
);
|
||||
const status = useStatus(loadStatus.save);
|
||||
const [twitchConfig, setTwitchConfig, loadStatus] = useModule(modules.twitchConfig);
|
||||
const status = useTimedStatus(loadStatus.save);
|
||||
const dispatch = useAppDispatch();
|
||||
const [revealClientSecret, setRevealClientSecret] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
@ -58,10 +56,7 @@ export default function TwitchAPISettings() {
|
|||
setTesting(true);
|
||||
if (twitchConfig) {
|
||||
try {
|
||||
await checkTwitchKeys(
|
||||
twitchConfig.api_client_id,
|
||||
twitchConfig.api_client_secret,
|
||||
);
|
||||
await checkTwitchKeys(twitchConfig.api_client_id, twitchConfig.api_client_secret);
|
||||
setTestResult({ open: true });
|
||||
} catch (e: unknown) {
|
||||
setTestResult({ open: true, error: e as Error });
|
||||
|
@ -77,9 +72,7 @@ export default function TwitchAPISettings() {
|
|||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SectionHeader spacing={'none'}>
|
||||
{t('pages.twitch-settings.api-subheader')}
|
||||
</SectionHeader>
|
||||
<SectionHeader spacing={'none'}>{t('pages.twitch-settings.api-subheader')}</SectionHeader>
|
||||
<TextBlock>{t('pages.twitch-settings.apiguide-1')}</TextBlock>
|
||||
<StepList>
|
||||
<Step>
|
||||
|
@ -112,9 +105,7 @@ export default function TwitchAPISettings() {
|
|||
</Step>
|
||||
</StepList>
|
||||
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
|
||||
<Label htmlFor="clientid">
|
||||
{t('pages.twitch-settings.app-client-id')}
|
||||
</Label>
|
||||
<Label htmlFor="clientid">{t('pages.twitch-settings.app-client-id')}</Label>
|
||||
<InputBox
|
||||
type="text"
|
||||
id="clientid"
|
||||
|
@ -135,10 +126,7 @@ export default function TwitchAPISettings() {
|
|||
<Field size="fullWidth">
|
||||
<Label htmlFor="clientsecret">
|
||||
{t('pages.twitch-settings.app-client-secret')}
|
||||
<RevealLink
|
||||
value={revealClientSecret}
|
||||
setter={setRevealClientSecret}
|
||||
/>
|
||||
<RevealLink value={revealClientSecret} setter={setRevealClientSecret} />
|
||||
</Label>
|
||||
<PasswordInputBox
|
||||
reveal={revealClientSecret}
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useLiveKeyString, useModule, useStatus } from '~/lib/react';
|
||||
import { useLiveKeyString, useModule, useTimedStatus } from '~/lib/react';
|
||||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import apiReducer, { modules } from '~/store/api/reducer';
|
||||
import { startAuthFlow } from '~/lib/twitch';
|
||||
import TwitchUserBlock from '~/ui/components/TwitchUserBlock';
|
||||
import { ExternalLinkIcon } from '@radix-ui/react-icons';
|
||||
import SaveButton from '../../../components/forms/SaveButton';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FlexRow,
|
||||
InputBox,
|
||||
Label,
|
||||
SectionHeader,
|
||||
TextBlock,
|
||||
} from '../../../theme';
|
||||
import { Button, Field, FlexRow, InputBox, Label, SectionHeader, TextBlock } from '../../../theme';
|
||||
|
||||
export default function TwitchChatSettings() {
|
||||
const [chatConfig, setChatConfig, loadStatus] = useModule(
|
||||
modules.twitchChatConfig,
|
||||
);
|
||||
const [chatConfig, setChatConfig, loadStatus] = useModule(modules.twitchChatConfig);
|
||||
const kv = useAppSelector((state) => state.api.client);
|
||||
const authKey = 'twitch/chat/chatter-account';
|
||||
const authKeyValue = useLiveKeyString('twitch/chat/chatter-account');
|
||||
const status = useStatus(loadStatus.save);
|
||||
const status = useTimedStatus(loadStatus.save);
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const disabled = status?.type === 'pending';
|
||||
|
@ -35,9 +25,7 @@ export default function TwitchChatSettings() {
|
|||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SectionHeader spacing={'none'}>
|
||||
{t('pages.twitch-settings.chat.chat-account')}
|
||||
</SectionHeader>
|
||||
<SectionHeader spacing={'none'}>{t('pages.twitch-settings.chat.chat-account')}</SectionHeader>
|
||||
<TextBlock>{t('pages.twitch-settings.chat.account-copy')}</TextBlock>
|
||||
<FlexRow align="left" spacing="1" css={{ marginBottom: '1rem' }}>
|
||||
<Button
|
||||
|
@ -68,22 +56,18 @@ export default function TwitchChatSettings() {
|
|||
|
||||
<SectionHeader>{t('pages.twitch-settings.chat.header')}</SectionHeader>
|
||||
<Field size="fullWidth">
|
||||
<Label htmlFor="bot-chat-history">
|
||||
{t('pages.twitch-settings.chat.cooldown-tip')}
|
||||
</Label>
|
||||
<Label htmlFor="bot-chat-history">{t('pages.twitch-settings.chat.cooldown-tip')}</Label>
|
||||
<InputBox
|
||||
type="number"
|
||||
id="bot-chat-history"
|
||||
required={true}
|
||||
disabled={disabled}
|
||||
defaultValue={
|
||||
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
|
||||
}
|
||||
defaultValue={chatConfig ? chatConfig.command_cooldown ?? 2 : undefined}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchChatConfigChanged({
|
||||
...chatConfig,
|
||||
command_cooldown: parseInt(ev.target.value, 10),
|
||||
command_cooldown: Number.parseInt(ev.target.value, 10),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -33,16 +33,12 @@ export default function TwitchEventSubSettings() {
|
|||
>
|
||||
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
|
||||
</Button>
|
||||
<SectionHeader>
|
||||
{t('pages.twitch-settings.events.current-status')}
|
||||
</SectionHeader>
|
||||
<SectionHeader>{t('pages.twitch-settings.events.current-status')}</SectionHeader>
|
||||
<TwitchUserBlock
|
||||
authKey={'twitch/auth-keys'}
|
||||
noUserMessage={t('pages.twitch-settings.events.err-no-user')}
|
||||
/>
|
||||
<SectionHeader>
|
||||
{t('pages.twitch-settings.events.sim-events')}
|
||||
</SectionHeader>
|
||||
<SectionHeader>{t('pages.twitch-settings.events.sim-events')}</SectionHeader>
|
||||
<ButtonGroup>
|
||||
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
|
||||
<Button
|
||||
|
|
|
@ -32,8 +32,7 @@ export const AlertOverlay = styled(AlertDialogPrimitive.Overlay, {
|
|||
export const AlertContainer = styled(AlertDialogPrimitive.Content, {
|
||||
backgroundColor: '$gray2',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
|
@ -117,5 +116,5 @@ export const IconButton = styled('button', {
|
|||
right: 15,
|
||||
|
||||
'&:hover': { backgroundColor: '$teal4' },
|
||||
'&:focus': { boxShadow: `0 0 0 2px $teal7` },
|
||||
'&:focus': { boxShadow: '0 0 0 2px $teal7' },
|
||||
});
|
||||
|
|
|
@ -38,8 +38,7 @@ const dialogPadding = '50px';
|
|||
export const DialogContainer = styled(DialogPrimitive.Content, {
|
||||
backgroundColor: '$gray2',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: dialogPadding,
|
||||
|
@ -128,5 +127,5 @@ export const IconButton = styled('button', {
|
|||
right: 15,
|
||||
|
||||
'&:hover': { backgroundColor: '$teal4' },
|
||||
'&:focus': { boxShadow: `0 0 0 2px $teal7` },
|
||||
'&:focus': { boxShadow: '0 0 0 2px $teal7' },
|
||||
});
|
||||
|
|
|
@ -221,10 +221,10 @@ const button = {
|
|||
borderRadius: '0',
|
||||
margin: '0 -1px',
|
||||
'&:first-child': {
|
||||
borderRadius: `$borderRadius$form 0 0 $borderRadius$form`,
|
||||
borderRadius: '$borderRadius$form 0 0 $borderRadius$form',
|
||||
},
|
||||
'&:last-child': {
|
||||
borderRadius: `0 $borderRadius$form $borderRadius$form 0`,
|
||||
borderRadius: '0 $borderRadius$form $borderRadius$form 0',
|
||||
},
|
||||
'&:hover': {
|
||||
zIndex: '1',
|
||||
|
|
|
@ -12,15 +12,11 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
|||
|
||||
const chatToInt: Record<string, number> = {};
|
||||
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||
.split('')
|
||||
.forEach((char, i) => {
|
||||
chatToInt[char] = i;
|
||||
});
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('').forEach((char, i) => {
|
||||
chatToInt[char] = i;
|
||||
});
|
||||
|
||||
export default function decode(
|
||||
string: string,
|
||||
): [number, number, number, number] {
|
||||
export default function decode(string: string): [number, number, number, number] {
|
||||
const result: number[] = [];
|
||||
|
||||
let shift = 0;
|
||||
|
|
Loading…
Reference in New Issue