switch to biome for format/linting, add auto lints

This commit is contained in:
Ash Keel 2024-04-30 18:33:37 +02:00
parent d276b734bf
commit af1f198eba
No known key found for this signature in database
GPG Key ID: 53A9E9A6035DD109
65 changed files with 921 additions and 6026 deletions

View File

@ -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/*'],
};

32
frontend/biome.json Normal file
View File

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

BIN
frontend/bun.lockb Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1 +1 @@
894313fcc17ff294db3c3171003cb162
67f333a2779a971376eeb65b025f5696

View File

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

View File

@ -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?');
}
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"extends":"../../../../tsconfig.json",
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"lib": ["es2019", "WebWorker"],
"lib": ["es2019", "WebWorker"]
}
}

View File

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

View File

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

View File

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

View File

@ -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));
}
},
);

View File

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

View File

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

View File

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

View File

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

View File

@ -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()));
},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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..." />

View File

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

View File

@ -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">&hellip;</span>
) : null}
{current > min + 2 ? <span className="pagination-ellipsis">&hellip;</span> : null}
{current > min + 1 ? (
<ToolbarButton
@ -138,9 +134,7 @@ function PageList({
{current + 1}
</ToolbarButton>
) : null}
{current < max - 2 ? (
<span className="pagination-ellipsis">&hellip;</span>
) : null}
{current < max - 2 ? <span className="pagination-ellipsis">&hellip;</span> : null}
{current < max - 1 ? (
<ToolbarButton
className="button pagination-link"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}),
);
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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