Compare commits

...

2 Commits

Author SHA1 Message Date
Ash Keel 33f775f0a2
biome format
Build / build (push) Successful in 2m6s Details
Test / test (push) Successful in 39s Details
2024-04-30 18:38:16 +02:00
Ash Keel af1f198eba
switch to biome for format/linting, add auto lints 2024-04-30 18:33:37 +02:00
86 changed files with 11173 additions and 16284 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/*'],
};

26
frontend/biome.json Normal file
View File

@ -0,0 +1,26 @@
{
"$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,
"lineWidth": 100
}
}

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 --write",
"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

@ -1,23 +1,23 @@
import { IsFatalError } from '@wailsapp/go/main/App';
import { EventsOn, EventsOff } from '@wailsapp/runtime/runtime';
import { useState, useEffect } from 'react';
import App from './ui/App';
import ErrorWindow from './ui/ErrorWindow';
import { IsFatalError } from "@wailsapp/go/main/App";
import { EventsOn, EventsOff } from "@wailsapp/runtime/runtime";
import { useState, useEffect } from "react";
import App from "./ui/App";
import ErrorWindow from "./ui/ErrorWindow";
export default function AppWrapper() {
const [fatalErrorEncountered, setFatalErrorStatus] = useState(false);
useEffect(() => {
void IsFatalError().then(setFatalErrorStatus);
EventsOn('fatalError', () => {
setFatalErrorStatus(true);
});
return () => {
EventsOff('fatalError');
};
}, []);
const [fatalErrorEncountered, setFatalErrorStatus] = useState(false);
useEffect(() => {
void IsFatalError().then(setFatalErrorStatus);
EventsOn("fatalError", () => {
setFatalErrorStatus(true);
});
return () => {
EventsOff("fatalError");
};
}, []);
if (fatalErrorEncountered) {
return <ErrorWindow />;
}
return <App />;
if (fatalErrorEncountered) {
return <ErrorWindow />;
}
return <App />;
}

View File

@ -1,152 +1,152 @@
const transport = {
method: 'webhook',
callback: 'https://example.com/webhooks/callback',
method: "webhook",
callback: "https://example.com/webhooks/callback",
};
const sub = {
id: 'f1c2a387-161a-49f9-a165-0f21d7a4e1c4',
status: 'enabled',
cost: 0,
condition: {
broadcaster_user_id: '1337',
},
created_at: '2019-11-16T10:11:12.123Z',
transport,
id: "f1c2a387-161a-49f9-a165-0f21d7a4e1c4",
status: "enabled",
cost: 0,
condition: {
broadcaster_user_id: "1337",
},
created_at: "2019-11-16T10:11:12.123Z",
transport,
};
export default {
'channel.update': {
subscription: {
...sub,
type: 'channel.update',
version: '1',
},
event: {
broadcaster_user_id: '1337',
broadcaster_user_login: 'cool_user',
broadcaster_user_name: 'Cool_User',
title: 'Best Stream Ever',
language: 'en',
category_id: '21779',
category_name: 'Fortnite',
is_mature: false,
},
},
'channel.follow': {
subscription: {
...sub,
type: 'channel.follow',
version: '1',
},
event: {
user_id: '1234',
user_login: 'cool_user',
user_name: 'Cool_User',
broadcaster_user_id: '1337',
broadcaster_user_login: 'cooler_user',
broadcaster_user_name: 'Cooler_User',
followed_at: '2020-07-15T18:16:11.17106713Z',
},
},
'channel.subscribe': {
subscription: {
...sub,
type: 'channel.subscribe',
version: '1',
},
event: {
user_id: '1234',
user_login: 'cool_user',
user_name: 'Cool_User',
broadcaster_user_id: '1337',
broadcaster_user_login: 'cooler_user',
broadcaster_user_name: 'Cooler_User',
tier: '1000',
is_gift: false,
},
},
'channel.subscription.gift': {
subscription: {
...sub,
type: 'channel.subscription.gift',
version: '1',
},
event: {
user_id: '1234',
user_login: 'cool_user',
user_name: 'Cool_User',
broadcaster_user_id: '1337',
broadcaster_user_login: 'cooler_user',
broadcaster_user_name: 'Cooler_User',
total: 2,
tier: '1000',
cumulative_total: 284, // null if anonymous or not shared by the user
is_anonymous: false,
},
},
'channel.subscription.message': {
subscription: {
...sub,
type: 'channel.subscription.message',
version: '1',
},
event: {
user_id: '1234',
user_login: 'cool_user',
user_name: 'Cool_User',
broadcaster_user_id: '1337',
broadcaster_user_login: 'cooler_user',
broadcaster_user_name: 'Cooler_User',
tier: '1000',
message: {
text: 'Love the stream! FevziGG',
emotes: [
{
end: 30,
id: '302976485',
},
],
},
cumulative_months: 15,
streak_months: 1, // null if not shared
duration_months: 6,
},
},
'channel.cheer': {
subscription: {
...sub,
type: 'channel.cheer',
version: '1',
},
event: {
is_anonymous: false,
user_id: '1234', // null if is_anonymous=true
user_login: 'cool_user', // null if is_anonymous=true
user_name: 'Cool_User', // null if is_anonymous=true
broadcaster_user_id: '1337',
broadcaster_user_login: 'cooler_user',
broadcaster_user_name: 'Cooler_User',
message: 'pogchamp',
bits: 1000,
},
},
'channel.raid': {
subscription: {
...sub,
type: 'channel.raid',
version: '1',
condition: {
to_broadcaster_user_id: '1337',
},
},
event: {
from_broadcaster_user_id: '1234',
from_broadcaster_user_login: 'cool_user',
from_broadcaster_user_name: 'Cool_User',
to_broadcaster_user_id: '1337',
to_broadcaster_user_login: 'cooler_user',
to_broadcaster_user_name: 'Cooler_User',
viewers: 9001,
},
},
"channel.update": {
subscription: {
...sub,
type: "channel.update",
version: "1",
},
event: {
broadcaster_user_id: "1337",
broadcaster_user_login: "cool_user",
broadcaster_user_name: "Cool_User",
title: "Best Stream Ever",
language: "en",
category_id: "21779",
category_name: "Fortnite",
is_mature: false,
},
},
"channel.follow": {
subscription: {
...sub,
type: "channel.follow",
version: "1",
},
event: {
user_id: "1234",
user_login: "cool_user",
user_name: "Cool_User",
broadcaster_user_id: "1337",
broadcaster_user_login: "cooler_user",
broadcaster_user_name: "Cooler_User",
followed_at: "2020-07-15T18:16:11.17106713Z",
},
},
"channel.subscribe": {
subscription: {
...sub,
type: "channel.subscribe",
version: "1",
},
event: {
user_id: "1234",
user_login: "cool_user",
user_name: "Cool_User",
broadcaster_user_id: "1337",
broadcaster_user_login: "cooler_user",
broadcaster_user_name: "Cooler_User",
tier: "1000",
is_gift: false,
},
},
"channel.subscription.gift": {
subscription: {
...sub,
type: "channel.subscription.gift",
version: "1",
},
event: {
user_id: "1234",
user_login: "cool_user",
user_name: "Cool_User",
broadcaster_user_id: "1337",
broadcaster_user_login: "cooler_user",
broadcaster_user_name: "Cooler_User",
total: 2,
tier: "1000",
cumulative_total: 284, // null if anonymous or not shared by the user
is_anonymous: false,
},
},
"channel.subscription.message": {
subscription: {
...sub,
type: "channel.subscription.message",
version: "1",
},
event: {
user_id: "1234",
user_login: "cool_user",
user_name: "Cool_User",
broadcaster_user_id: "1337",
broadcaster_user_login: "cooler_user",
broadcaster_user_name: "Cooler_User",
tier: "1000",
message: {
text: "Love the stream! FevziGG",
emotes: [
{
end: 30,
id: "302976485",
},
],
},
cumulative_months: 15,
streak_months: 1, // null if not shared
duration_months: 6,
},
},
"channel.cheer": {
subscription: {
...sub,
type: "channel.cheer",
version: "1",
},
event: {
is_anonymous: false,
user_id: "1234", // null if is_anonymous=true
user_login: "cool_user", // null if is_anonymous=true
user_name: "Cool_User", // null if is_anonymous=true
broadcaster_user_id: "1337",
broadcaster_user_login: "cooler_user",
broadcaster_user_name: "Cooler_User",
message: "pogchamp",
bits: 1000,
},
},
"channel.raid": {
subscription: {
...sub,
type: "channel.raid",
version: "1",
condition: {
to_broadcaster_user_id: "1337",
},
},
event: {
from_broadcaster_user_id: "1234",
from_broadcaster_user_login: "cool_user",
from_broadcaster_user_name: "Cool_User",
to_broadcaster_user_id: "1337",
to_broadcaster_user_login: "cooler_user",
to_broadcaster_user_name: "Cooler_User",
viewers: 9001,
},
},
};

View File

@ -1,28 +1,28 @@
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';
import { StrictMode } from 'react';
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { HashRouter } from "react-router-dom";
import { StrictMode } from "react";
import 'inter-ui/inter.css';
import 'inter-ui/inter-variable.css';
import '@fontsource/space-mono/index.css';
import 'normalize.css/normalize.css';
import './locale/setup';
import "inter-ui/inter.css";
import "inter-ui/inter-variable.css";
import "@fontsource/space-mono/index.css";
import "normalize.css/normalize.css";
import "./locale/setup";
import store from './store';
import { globalStyles } from './ui/theme';
import AppWrapper from './AppWrapper';
import store from "./store";
import { globalStyles } from "./ui/theme";
import AppWrapper from "./AppWrapper";
globalStyles();
const main = document.getElementById('main');
const main = document.getElementById("main");
const root = createRoot(main);
root.render(
<Provider store={store}>
<HashRouter>
<StrictMode>
<AppWrapper />
</StrictMode>
</HashRouter>
</Provider>,
<Provider store={store}>
<HashRouter>
<StrictMode>
<AppWrapper />
</StrictMode>
</HashRouter>
</Provider>,
);

View File

@ -1,469 +1,388 @@
export enum EventSubNotificationType {
ChannelUpdated = 'channel.update',
UserUpdated = 'user.update',
Cheered = 'channel.cheer',
Raided = 'channel.raid',
CustomRewardAdded = 'channel.channel_points_custom_reward.add',
CustomRewardRemoved = 'channel.channel_points_custom_reward.remove',
CustomRewardUpdated = 'channel.channel_points_custom_reward.update',
CustomRewardRedemptionAdded = 'channel.channel_points_custom_reward_redemption.add',
CustomRewardRedemptionUpdated = 'channel.channel_points_custom_reward_redemption.update',
Followed = 'channel.follow',
GoalBegan = 'channel.goal.begin',
GoalEnded = 'channel.goal.end',
GoalProgress = 'channel.goal.progress',
HypeTrainBegan = 'channel.hype_train.begin',
HypeTrainEnded = 'channel.hype_train.end',
HypeTrainProgress = 'channel.hype_train.progress',
ModeratorAdded = 'channel.moderator.add',
ModeratorRemoved = 'channel.moderator.remove',
PollBegan = 'channel.poll.begin',
PollEnded = 'channel.poll.end',
PollProgress = 'channel.poll.progress',
PredictionBegan = 'channel.prediction.begin',
PredictionEnded = 'channel.prediction.end',
PredictionLocked = 'channel.prediction.lock',
PredictionProgress = 'channel.prediction.progress',
StreamWentOffline = 'stream.offline',
StreamWentOnline = 'stream.online',
Subscription = 'channel.subscribe',
SubscriptionEnded = 'channel.subscription.end',
SubscriptionGifted = 'channel.subscription.gift',
SubscriptionWithMessage = 'channel.subscription.message',
ViewerBanned = 'channel.ban',
ViewerUnbanned = 'channel.unban',
ChannelUpdated = "channel.update",
UserUpdated = "user.update",
Cheered = "channel.cheer",
Raided = "channel.raid",
CustomRewardAdded = "channel.channel_points_custom_reward.add",
CustomRewardRemoved = "channel.channel_points_custom_reward.remove",
CustomRewardUpdated = "channel.channel_points_custom_reward.update",
CustomRewardRedemptionAdded = "channel.channel_points_custom_reward_redemption.add",
CustomRewardRedemptionUpdated = "channel.channel_points_custom_reward_redemption.update",
Followed = "channel.follow",
GoalBegan = "channel.goal.begin",
GoalEnded = "channel.goal.end",
GoalProgress = "channel.goal.progress",
HypeTrainBegan = "channel.hype_train.begin",
HypeTrainEnded = "channel.hype_train.end",
HypeTrainProgress = "channel.hype_train.progress",
ModeratorAdded = "channel.moderator.add",
ModeratorRemoved = "channel.moderator.remove",
PollBegan = "channel.poll.begin",
PollEnded = "channel.poll.end",
PollProgress = "channel.poll.progress",
PredictionBegan = "channel.prediction.begin",
PredictionEnded = "channel.prediction.end",
PredictionLocked = "channel.prediction.lock",
PredictionProgress = "channel.prediction.progress",
StreamWentOffline = "stream.offline",
StreamWentOnline = "stream.online",
Subscription = "channel.subscribe",
SubscriptionEnded = "channel.subscription.end",
SubscriptionGifted = "channel.subscription.gift",
SubscriptionWithMessage = "channel.subscription.message",
ViewerBanned = "channel.ban",
ViewerUnbanned = "channel.unban",
}
export interface EventSubSubscription {
id: string;
type: EventSubNotificationType;
version: string;
status: string;
created_at: string;
cost: number;
id: string;
type: EventSubNotificationType;
version: string;
status: string;
created_at: string;
cost: number;
}
export interface EventSubNotification {
subscription: EventSubSubscription;
event: unknown;
date?: string;
subscription: EventSubSubscription;
event: unknown;
date?: string;
}
export const unwrapEvent = (message: EventSubNotification) =>
({
type: message.subscription.type,
subscription: message.subscription,
event: message.event,
} as EventSubMessage);
({
type: message.subscription.type,
subscription: message.subscription,
event: message.event,
}) as EventSubMessage;
interface TypedEventSubNotification<
T extends EventSubNotificationType,
Payload,
> {
type: T;
subscription: EventSubSubscription;
event: 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.Cheered, CheerEventData>
| TypedEventSubNotification<EventSubNotificationType.Raided, RaidEventData>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardAdded,
ChannelRewardEventData
>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardRemoved,
ChannelRewardEventData
>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardUpdated,
ChannelRewardEventData
>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardRedemptionAdded,
ChannelRedemptionEventData<false>
>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardRedemptionUpdated,
ChannelRedemptionEventData<true>
>
| 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.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.SubscriptionGifted,
SubscriptionGiftedEventData
>
| TypedEventSubNotification<
EventSubNotificationType.SubscriptionWithMessage,
SubscriptionMessageEventData
>
| TypedEventSubNotification<
EventSubNotificationType.ViewerBanned,
UserBannedEventData
>
| TypedEventSubNotification<
EventSubNotificationType.ViewerUnbanned,
UserUnbannedEventData
>;
| 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.CustomRewardRedemptionAdded,
ChannelRedemptionEventData<false>
>
| TypedEventSubNotification<
EventSubNotificationType.CustomRewardRedemptionUpdated,
ChannelRedemptionEventData<true>
>
| 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.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.SubscriptionGifted,
SubscriptionGiftedEventData
>
| TypedEventSubNotification<
EventSubNotificationType.SubscriptionWithMessage,
SubscriptionMessageEventData
>
| TypedEventSubNotification<EventSubNotificationType.ViewerBanned, UserBannedEventData>
| TypedEventSubNotification<EventSubNotificationType.ViewerUnbanned, UserUnbannedEventData>;
export interface StreamEventData {
broadcaster_user_id: string;
broadcaster_user_login: string;
broadcaster_user_name: string;
broadcaster_user_id: string;
broadcaster_user_login: string;
broadcaster_user_name: string;
}
export interface StreamWentOnlineEventData extends StreamEventData {
id: string;
type: 'live' | 'playlist' | 'watch_party' | 'premiere' | 'rerun';
started_at: string;
id: string;
type: "live" | "playlist" | "watch_party" | "premiere" | "rerun";
started_at: string;
}
type Optional<field extends string, Extra> =
| ({ [key in field]: true } & Extra)
| { [key in field]: false };
| ({ [key in field]: true } & Extra)
| { [key in field]: false };
type UserBannedEventData = StreamEventData & {
user_id: string;
user_login: string;
user_name: string;
moderator_user_id: string;
moderator_user_login: string;
moderator_user_name: string;
reason: string;
banned_at: string;
} & Optional<'is_permanent', { ends_at: string }>;
user_id: string;
user_login: string;
user_name: string;
moderator_user_id: string;
moderator_user_login: string;
moderator_user_name: string;
reason: string;
banned_at: string;
} & Optional<"is_permanent", { ends_at: string }>;
export interface UserUnbannedEventData {
user_id: string;
user_login: string;
user_name: string;
moderator_user_id: string;
moderator_user_login: string;
moderator_user_name: string;
user_id: string;
user_login: string;
user_name: string;
moderator_user_id: string;
moderator_user_login: string;
moderator_user_name: string;
}
export interface ChannelUpdatedEventData extends StreamEventData {
title: string;
language: string;
category_id: string;
category_name: string;
is_mature: boolean;
title: string;
language: string;
category_id: string;
category_name: string;
is_mature: boolean;
}
export interface FollowEventData extends StreamEventData {
user_id: string;
user_login: string;
user_name: string;
followed_at: string;
user_id: string;
user_login: string;
user_name: string;
followed_at: string;
}
export interface UserUpdatedEventData {
user_id: string;
user_login: string;
user_name: string;
email?: string;
email_verified: boolean;
description: string;
user_id: string;
user_login: string;
user_name: string;
email?: string;
email_verified: boolean;
description: string;
}
export interface CheerEventData extends StreamEventData {
is_anonymous: boolean;
user_id: string | null;
user_login: string | null;
user_name: string | null;
message: string;
bits: number;
is_anonymous: boolean;
user_id: string | null;
user_login: string | null;
user_name: string | null;
message: string;
bits: number;
}
export interface RaidEventData {
from_broadcaster_user_id: string;
from_broadcaster_user_login: string;
from_broadcaster_user_name: string;
to_broadcaster_user_id: string;
to_broadcaster_user_login: string;
to_broadcaster_user_name: string;
viewers: number;
from_broadcaster_user_id: string;
from_broadcaster_user_login: string;
from_broadcaster_user_name: string;
to_broadcaster_user_id: string;
to_broadcaster_user_login: string;
to_broadcaster_user_name: string;
viewers: number;
}
export interface ChannelRewardEventData extends StreamEventData {
id: string;
is_enabled: boolean;
is_paused: boolean;
is_in_stock: boolean;
title: string;
cost: number;
prompt: string;
is_user_input_required: boolean;
should_redemptions_skip_request_queue: boolean;
cooldown_expires_at: string | null;
redemptions_redeemed_current_stream: number | null;
max_per_stream: Optional<'is_enabled', { value: number }>;
max_per_user_per_stream: Optional<'is_enabled', { value: number }>;
global_cooldown: Optional<'is_enabled', { seconds: number }>;
background_color: string;
image: {
url_1x: string;
url_2x: string;
url_4x: string;
} | null;
default_image: {
url_1x: string;
url_2x: string;
url_4x: string;
};
id: string;
is_enabled: boolean;
is_paused: boolean;
is_in_stock: boolean;
title: string;
cost: number;
prompt: string;
is_user_input_required: boolean;
should_redemptions_skip_request_queue: boolean;
cooldown_expires_at: string | null;
redemptions_redeemed_current_stream: number | null;
max_per_stream: Optional<"is_enabled", { value: number }>;
max_per_user_per_stream: Optional<"is_enabled", { value: number }>;
global_cooldown: Optional<"is_enabled", { seconds: number }>;
background_color: string;
image: {
url_1x: string;
url_2x: string;
url_4x: string;
} | null;
default_image: {
url_1x: string;
url_2x: string;
url_4x: string;
};
}
export interface ChannelRedemptionEventData<Updated extends boolean>
extends StreamEventData {
id: string;
user_id: string;
user_login: string;
user_name: string;
user_input: string;
status: Updated extends true
? 'fulfilled' | 'canceled'
: 'unfulfilled' | 'unknown' | 'fulfilled' | 'canceled';
reward: ChannelRewardEventData;
redeemed_at: string;
export interface ChannelRedemptionEventData<Updated extends boolean> extends StreamEventData {
id: string;
user_id: string;
user_login: string;
user_name: string;
user_input: string;
status: Updated extends true
? "fulfilled" | "canceled"
: "unfulfilled" | "unknown" | "fulfilled" | "canceled";
reward: ChannelRewardEventData;
redeemed_at: string;
}
export interface GoalEventData extends StreamEventData {
id: string;
type: 'follower' | 'subscription';
description: string;
current_amount: number;
target_amount: number;
started_at: Date;
id: string;
type: "follower" | "subscription";
description: string;
current_amount: number;
target_amount: number;
started_at: Date;
}
export interface GoalEndedEventData extends GoalEventData {
is_achieved: boolean;
ended_at: Date;
is_achieved: boolean;
ended_at: Date;
}
export interface HypeTrainContribution {
user_id: string;
user_login: string;
user_name: string;
type: 'bits' | 'subscription' | 'other';
total: number;
user_id: string;
user_login: string;
user_name: string;
type: "bits" | "subscription" | "other";
total: number;
}
interface HypeTrainBaseData extends StreamEventData {
id: string;
level: number;
total: number;
top_contributions:
| [HypeTrainContribution]
| [HypeTrainContribution, HypeTrainContribution]
| null;
started_at: string;
id: string;
level: number;
total: number;
top_contributions:
| [HypeTrainContribution]
| [HypeTrainContribution, HypeTrainContribution]
| null;
started_at: string;
}
export interface HypeTrainEventData extends HypeTrainBaseData {
progress: number;
goal: number;
last_contribution: HypeTrainContribution;
expires_at: string;
progress: number;
goal: number;
last_contribution: HypeTrainContribution;
expires_at: string;
}
export interface HypeTrainEndedEventData extends HypeTrainBaseData {
ended_at: string;
cooldown_ends_at: string;
ended_at: string;
cooldown_ends_at: string;
}
export interface ModeratorEventData extends StreamEventData {
user_id: string;
user_login: string;
user_name: string;
user_id: string;
user_login: string;
user_name: string;
}
interface PollBaseData<Running extends boolean> extends StreamEventData {
id: string;
title: string;
choices: Running extends true
? {
id: string;
title: string;
bits_votes: number;
channel_points_votes: number;
votes: number;
}
: {
id: string;
title: string;
};
bits_voting: Optional<'is_enabled', { amount_per_vote: number }>;
channel_points_voting: Optional<'is_enabled', { amount_per_vote: number }>;
started_at: string;
id: string;
title: string;
choices: Running extends true
? {
id: string;
title: string;
bits_votes: number;
channel_points_votes: number;
votes: number;
}
: {
id: string;
title: string;
};
bits_voting: Optional<"is_enabled", { amount_per_vote: number }>;
channel_points_voting: Optional<"is_enabled", { amount_per_vote: number }>;
started_at: string;
}
export interface PollEventData<Running extends boolean>
extends PollBaseData<Running> {
started_at: string;
ends_at: string;
export interface PollEventData<Running extends boolean> extends PollBaseData<Running> {
started_at: string;
ends_at: string;
}
export interface PollEndedEventData extends PollBaseData<true> {
status: 'completed' | 'archived' | 'terminated';
ended_at: string;
status: "completed" | "archived" | "terminated";
ended_at: string;
}
type PredictionColor = 'blue' | 'pink';
type PredictionColor = "blue" | "pink";
interface Outcome<Color extends PredictionColor> {
id: string;
title: string;
color: Color;
id: string;
title: string;
color: Color;
}
interface RunningOutcome<Color extends PredictionColor> extends Outcome<Color> {
users: number;
channel_points: number;
top_predictors: {
user_name: string;
user_login: string;
user_id: string;
channel_points_won: number | null;
channel_points_used: number;
}[];
users: number;
channel_points: number;
top_predictors: {
user_name: string;
user_login: string;
user_id: string;
channel_points_won: number | null;
channel_points_used: number;
}[];
}
type UnorderedTuple<A, B> = [A, B] | [B, A];
interface PredictionBaseData<Running extends boolean> extends StreamEventData {
id: string;
title: string;
started_at: string;
outcomes: Running extends true
? UnorderedTuple<RunningOutcome<'blue'>, RunningOutcome<'pink'>>
: UnorderedTuple<Outcome<'blue'>, Outcome<'pink'>>;
id: string;
title: string;
started_at: string;
outcomes: Running extends true
? UnorderedTuple<RunningOutcome<"blue">, RunningOutcome<"pink">>
: UnorderedTuple<Outcome<"blue">, Outcome<"pink">>;
}
export interface PredictionEventData<Running extends boolean>
extends PredictionBaseData<Running> {
locks_at: string;
export interface PredictionEventData<Running extends boolean> extends PredictionBaseData<Running> {
locks_at: string;
}
export interface PredictionLockedEventData extends PredictionBaseData<true> {
locked_at: string;
locked_at: string;
}
export interface PredictionEndedEventData extends PredictionBaseData<true> {
winning_outcome_id: string | null;
status: 'resolved' | 'canceled';
ended_at: string;
winning_outcome_id: string | null;
status: "resolved" | "canceled";
ended_at: string;
}
interface SubscriptionBaseData extends StreamEventData {
user_id: string;
user_login: string;
user_name: string;
tier: '1000' | '2000' | '3000';
user_id: string;
user_login: string;
user_name: string;
tier: "1000" | "2000" | "3000";
}
export interface SubscriptionEventData extends SubscriptionBaseData {
is_gift: boolean;
is_gift: boolean;
}
export interface SubscriptionGiftedEventData extends SubscriptionBaseData {
total: number;
cumulative_total: number | null;
is_anonymous: boolean;
total: number;
cumulative_total: number | null;
is_anonymous: boolean;
}
export interface SubscriptionMessageEventData extends SubscriptionBaseData {
message: {
text: string;
emotes: {
begin: number;
end: number;
id: string;
}[]; // Oh god not this again
};
cumulative_months: number;
streak_months: number | null;
duration_months: number;
message: {
text: string;
emotes: {
begin: number;
end: number;
id: string;
}[]; // Oh god not this again
};
cumulative_months: number;
streak_months: number | null;
duration_months: number;
}

View File

@ -1,11 +1,11 @@
import { ExtensionEntry } from '~/store/extensions/reducer';
import type { ExtensionEntry } from "~/store/extensions/reducer";
import {
ExtensionStatus,
ExtensionDependencies,
ExtensionHostMessage,
ExtensionHostCommand,
ExtensionRunOptions,
} from './types';
ExtensionStatus,
type ExtensionDependencies,
type ExtensionHostMessage,
type ExtensionHostCommand,
type ExtensionRunOptions,
} from "./types";
export const blankTemplate = (slug: string) => `// ==Extension==
// @name ${slug}
@ -17,111 +17,104 @@ export const blankTemplate = (slug: string) => `// ==Extension==
`;
export class Extension extends EventTarget {
private readonly worker: Worker;
private readonly worker: Worker;
private workerStatus = ExtensionStatus.GettingReady;
private workerStatus = ExtensionStatus.GettingReady;
private workerError?: ErrorEvent | Error;
private workerError?: ErrorEvent | Error;
constructor(
public readonly info: ExtensionEntry,
dependencies: ExtensionDependencies,
runOptions: ExtensionRunOptions = { autostart: false },
) {
super();
constructor(
public readonly info: ExtensionEntry,
dependencies: ExtensionDependencies,
runOptions: ExtensionRunOptions = { autostart: false },
) {
super();
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 = 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);
// Initialize ext host
this.send({
kind: 'arguments',
source: info.source,
options: runOptions,
name: info.name,
dependencies,
});
}
// Initialize ext host
this.send({
kind: "arguments",
source: info.source,
options: runOptions,
name: info.name,
dependencies,
});
}
private send(cmd: ExtensionHostCommand) {
this.worker.postMessage(cmd);
}
private send(cmd: ExtensionHostCommand) {
this.worker.postMessage(cmd);
}
private messageReceived(ev: MessageEvent<ExtensionHostMessage>) {
const msg = ev.data;
switch (msg.kind) {
case 'status-change':
this.status = msg.status;
break;
case 'error':
if (msg.error instanceof Error) {
this.workerError = msg.error;
} else {
this.workerError = new Error(JSON.stringify(msg.error));
}
this.status = ExtensionStatus.Error;
break;
}
}
private messageReceived(ev: MessageEvent<ExtensionHostMessage>) {
const msg = ev.data;
switch (msg.kind) {
case "status-change":
this.status = msg.status;
break;
case "error":
if (msg.error instanceof Error) {
this.workerError = msg.error;
} else {
this.workerError = new Error(JSON.stringify(msg.error));
}
this.status = ExtensionStatus.Error;
break;
}
}
private set status(newValue: ExtensionStatus) {
this.workerStatus = newValue;
this.dispatchEvent(new CustomEvent('statusChanged', { detail: newValue }));
}
private set status(newValue: ExtensionStatus) {
this.workerStatus = newValue;
this.dispatchEvent(new CustomEvent("statusChanged", { detail: newValue }));
}
public get status() {
return this.workerStatus;
}
public get status() {
return this.workerStatus;
}
public get error() {
return this.workerError;
}
public get error() {
return this.workerError;
}
public get running() {
return (
this.status === ExtensionStatus.Running ||
this.status === ExtensionStatus.Finished
);
}
public get running() {
return this.status === ExtensionStatus.Running || this.status === ExtensionStatus.Finished;
}
start() {
switch (this.status) {
case ExtensionStatus.Ready:
return this.send({
kind: 'start',
});
case ExtensionStatus.GettingReady:
case ExtensionStatus.Error:
throw new Error('extension is not ready');
case ExtensionStatus.Running:
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?',
);
}
}
start() {
switch (this.status) {
case ExtensionStatus.Ready:
return this.send({
kind: "start",
});
case ExtensionStatus.GettingReady:
case ExtensionStatus.Error:
throw new Error("extension is not ready");
case ExtensionStatus.Running:
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?");
}
}
stop() {
if (this.status === ExtensionStatus.Terminated) {
return;
}
this.worker.terminate();
this.status = ExtensionStatus.Terminated;
}
stop() {
if (this.status === ExtensionStatus.Terminated) {
return;
}
this.worker.terminate();
this.status = ExtensionStatus.Terminated;
}
dispose() {
this.stop();
}
dispose() {
this.stop();
}
}
export default { Extension };

View File

@ -6,43 +6,41 @@
// ==/Extension==
interface ExtensionMetadata {
name?: string;
version?: string;
author?: string;
description?: string;
apiversion: string;
name?: string;
version?: string;
author?: string;
description?: string;
apiversion: string;
}
export function parseExtensionMetadata(
source: string,
): ExtensionMetadata | null {
// Find metadata block
const start = source.indexOf('// ==Extension==');
const end = source.indexOf('// ==/Extension==', start);
if (start < 0 || end < 0) {
// No block, return null
return null;
}
export function parseExtensionMetadata(source: string): ExtensionMetadata | null {
// Find metadata block
const start = source.indexOf("// ==Extension==");
const end = source.indexOf("// ==/Extension==", start);
if (start < 0 || end < 0) {
// No block, return null
return null;
}
// Extract metadata
const metadata = Object.fromEntries(
source
.substring(start, end)
.trim()
.split('\n')
.map((line) => line.trim().match(/^\s*\/\/\s*@([^\s]+)\s+(.+)/))
.filter((matches) => matches && matches.length > 2)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([_, key, value]) => [key, value]),
);
// Extract metadata
const metadata = Object.fromEntries(
source
.substring(start, end)
.trim()
.split("\n")
.map((line) => line.trim().match(/^\s*\/\/\s*@([^\s]+)\s+(.+)/))
.filter((matches) => matches && matches.length > 2)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([_, key, value]) => [key, value]),
);
return {
name: metadata.name,
version: metadata.version,
author: metadata.author,
description: metadata.description,
apiversion: metadata.apiversion ?? '3.1.0',
};
return {
name: metadata.name,
version: metadata.version,
author: metadata.author,
description: metadata.description,
apiversion: metadata.apiversion ?? "3.1.0",
};
}
export default { parseExtensionMetadata };

View File

@ -1,24 +1,24 @@
import decodeVLQ from '../../vendor/vlq/decode';
import decodeVLQ from "../../vendor/vlq/decode";
interface SourceMap {
file: string;
version: 3;
sources: string[];
names: string[];
mappings: string;
sourceRoot: string;
file: string;
version: 3;
sources: string[];
names: string[];
mappings: string;
sourceRoot: string;
}
export type SourceMapMappings = [number, number, number, number][][];
export function parseSourceMap(sourceMapText: string): SourceMapMappings {
const sourceMap = JSON.parse(sourceMapText) as SourceMap;
return sourceMap.mappings
.split(';')
.map((m) => m.split(','))
.map((line) => line.map(decodeVLQ));
const sourceMap = JSON.parse(sourceMapText) as SourceMap;
return sourceMap.mappings
.split(";")
.map((m) => m.split(","))
.map((line) => line.map(decodeVLQ));
}
export function mapError(error: Error, mappings: SourceMapMappings) {
/* TODO */
/* TODO */
}

View File

@ -1,43 +1,43 @@
export interface ExtensionDependencies {
kilovolt: {
address: string;
password?: string;
};
kilovolt: {
address: string;
password?: string;
};
}
export interface ExtensionOptions {
enabled: boolean;
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 {
GettingReady = 'not-ready',
Ready = 'ready',
Running = 'running',
Finished = 'main-loop-finished',
Error = 'error',
Terminated = 'terminated',
GettingReady = "not-ready",
Ready = "ready",
Running = "running",
Finished = "main-loop-finished",
Error = "error",
Terminated = "terminated",
}
export type ExtensionHostCommand = EHParamMessage | EHStartMessage;
export type ExtensionHostMessage = EHStatusChangeMessage | EHErrorMessage;
interface EHParamMessage {
kind: 'arguments';
options: ExtensionRunOptions;
dependencies: ExtensionDependencies;
source: string;
name: string;
kind: "arguments";
options: ExtensionRunOptions;
dependencies: ExtensionDependencies;
source: string;
name: string;
}
interface EHStartMessage {
kind: 'start';
kind: "start";
}
interface EHStatusChangeMessage {
kind: 'status-change';
status: ExtensionStatus;
kind: "status-change";
status: ExtensionStatus;
}
interface EHErrorMessage {
kind: 'error';
error: unknown;
kind: "error";
error: unknown;
}

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 Kilovolt from "@strimertul/kilovolt-client";
import ts from "typescript";
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;
@ -21,90 +14,90 @@ let name: string;
let extensionStatus = ExtensionStatus.GettingReady;
function setStatus(status: ExtensionStatus) {
extensionStatus = status;
sendMessage({
kind: 'status-change',
status,
});
extensionStatus = status;
sendMessage({
kind: "status-change",
status,
});
}
function log(level: string, sourceMap: SourceMapMappings) {
// eslint-disable-next-line func-names
return function (...args: { toString(): string }[]) {
const message = args.join(' ');
void kv.putJSON('strimertul/@log', {
level,
message,
data: {
extension: name,
},
});
};
function log(level: string, _sourceMap: SourceMapMappings) {
// eslint-disable-next-line func-names
return (...args: { toString(): string }[]) => {
const message = args.join(" ");
void kv.putJSON("strimertul/@log", {
level,
message,
data: {
extension: name,
},
});
};
}
function start() {
if (!extFn || !kv || extensionStatus !== ExtensionStatus.Ready) {
throw new Error('extension not ready');
}
if (!extFn || !kv || extensionStatus !== ExtensionStatus.Ready) {
throw new Error("extension not ready");
}
void extFn(kv)
.then(() => {
setStatus(ExtensionStatus.Finished);
})
.catch((error: Error) => {
sendMessage({
kind: 'error',
error,
});
});
void extFn(kv)
.then(() => {
setStatus(ExtensionStatus.Finished);
})
.catch((error: Error) => {
sendMessage({
kind: "error",
error,
});
});
setStatus(ExtensionStatus.Running);
setStatus(ExtensionStatus.Running);
}
onmessage = async (ev: MessageEvent<ExtensionHostCommand>) => {
const cmd = ev.data;
switch (cmd.kind) {
case 'arguments': {
name = cmd.name;
addEventListener("message", async (ev: MessageEvent<ExtensionHostCommand>) => {
const cmd = ev.data;
switch (cmd.kind) {
case "arguments": {
name = cmd.name;
// Create Kilovolt instance
kv = new Kilovolt(cmd.dependencies.kilovolt.address, {
password: cmd.dependencies.kilovolt.password,
});
await kv.connect();
// Create Kilovolt instance
kv = new Kilovolt(cmd.dependencies.kilovolt.address, {
password: cmd.dependencies.kilovolt.password,
});
await kv.connect();
try {
// Transpile TS into JS
const out = ts.transpileModule(cmd.source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
sourceMap: true,
},
});
try {
// Transpile TS into JS
const out = ts.transpileModule(cmd.source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
sourceMap: true,
},
});
const sourceMap = parseSourceMap(out.sourceMapText);
const sourceMap = parseSourceMap(out.sourceMapText);
// Replace console.* methods with something that logs to UI
console.log = log('info', sourceMap);
console.info = log('info', sourceMap);
console.warn = log('warn', sourceMap);
console.error = log('error', sourceMap);
// Replace console.* methods with something that logs to UI
console.log = log("info", sourceMap);
console.info = log("info", sourceMap);
console.warn = log("warn", sourceMap);
console.error = log("error", sourceMap);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
extFn = ExtensionFunction.constructor('kv', out.outputText);
setStatus(ExtensionStatus.Ready);
} catch (error: unknown) {
sendMessage({
kind: 'error',
error,
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
extFn = ExtensionFunction.constructor("kv", out.outputText);
setStatus(ExtensionStatus.Ready);
} catch (error: unknown) {
sendMessage({
kind: "error",
error,
});
}
start();
break;
}
case 'start':
start();
break;
}
};
start();
break;
}
case "start":
start();
break;
}
});

View File

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

View File

@ -1,144 +1,135 @@
import {
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 apiReducer, { getUserPoints } from '~/store/api/reducer';
import {
APIState,
LoyaltyPointsEntry,
LoyaltyStorage,
RequestStatus,
} from '~/store/api/types';
import type { ActionCreatorWithOptionalPayload, AsyncThunk, Draft } from "@reduxjs/toolkit";
import { useEffect, useState } from "react";
import type { KilovoltMessage, SubscriptionHandler } from "@strimertul/kilovolt-client";
import { useAppDispatch, useAppSelector } from "~/store";
import apiReducer, { getUserPoints } from "~/store/api/reducer";
import type {
APIState,
LoyaltyPointsEntry,
LoyaltyStorage,
RequestStatus,
} from "~/store/api/types";
interface LoadStatus {
load: RequestStatus;
save: RequestStatus;
load: RequestStatus;
save: RequestStatus;
}
export const useKilovoltClient = () => useAppSelector((state) => state.api.client);
export function useLiveKeyString(key: string) {
const client = useSelector((state: RootState) => state.api.client);
const [data, setData] = useState<string>(null);
const client = useKilovoltClient();
const [data, setData] = useState<string>(null);
useEffect(() => {
const subscriber: SubscriptionHandler = (v) => setData(v);
void client.getKey(key).then((value) => setData(value));
void client.subscribeKey(key, subscriber);
return () => {
void client.unsubscribeKey(key, subscriber);
};
}, []);
useEffect(() => {
const subscriber: SubscriptionHandler = (v) => setData(v);
void client.getKey(key).then((value) => setData(value));
void client.subscribeKey(key, subscriber);
return () => {
void client.unsubscribeKey(key, subscriber);
};
}, [key]);
return data;
return data;
}
export function useLiveKey<T>(key: string): T {
const data = useLiveKeyString(key);
return data ? (JSON.parse(data) as T) : null;
const data = useLiveKeyString(key);
return data ? (JSON.parse(data) as T) : null;
}
export function useModule<T>({
key,
selector,
getter,
setter,
asyncSetter,
key,
selector,
getter,
setter,
asyncSetter,
}: {
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, {}>;
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}`],
);
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(getter());
const subscriber: SubscriptionHandler = (newValue) => {
void dispatch(asyncSetter(JSON.parse(newValue) as T));
};
void client.subscribeKey(key, subscriber);
return () => {
void client.unsubscribeKey(key, subscriber);
dispatch(
apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]),
);
};
}, []);
return [
data,
setter,
{
load: loadStatus,
save: saveStatus,
},
];
key: string;
selector: (state: Draft<APIState>) => T;
getter: AsyncThunk<T, void, unknown>;
setter: AsyncThunk<KilovoltMessage, T, unknown>;
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
}): [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) => {
void dispatch(asyncSetter(JSON.parse(newValue) as T));
};
void client.subscribeKey(key, subscriber);
return () => {
void client.unsubscribeKey(key, subscriber);
dispatch(apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]));
};
}, [key, getter, asyncSetter]);
return [
data,
setter,
{
load: loadStatus,
save: saveStatus,
},
];
}
export function useStatus(
status: RequestStatus | null,
interval = 5000,
): RequestStatus | null {
const [localStatus, setlocalStatus] = useState(status);
const maxTime = Date.now() - interval;
useEffect(() => {
const remaining = status ? status.updated.getTime() - maxTime : null;
if (remaining) {
setTimeout(() => {
setlocalStatus(null);
}, remaining);
}
setlocalStatus(status);
}, [status]);
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, setMaxTime] = useState(0);
return status?.updated.getTime() > maxTime ? localStatus : null;
useEffect(() => {
const timeout = Date.now() - interval;
setMaxTime(timeout);
const remaining = status ? status.updated.getTime() - timeout : null;
if (remaining) {
setTimeout(() => {
setlocalStatus(null);
}, remaining);
}
setlocalStatus(status);
}, [status]);
return status?.updated.getTime() > maxTime ? localStatus : null;
}
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 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 client.subscribePrefix(prefix, subscriber);
return () => {
void client.unsubscribePrefix(prefix, subscriber);
};
}, []);
return data;
const prefix = "loyalty/points/";
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 client.subscribePrefix(prefix, subscriber);
return () => {
void client.unsubscribePrefix(prefix, subscriber);
};
}, []);
return data;
}
export default {
useModule,
useStatus,
useUserPoints,
useModule,
useStatus: useTimedStatus,
useUserPoints,
};

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,14 @@
export function getInterval(duration: number): [number, number] {
if (duration < 60) {
return [duration, 1];
}
if (duration % 3600 === 0) {
return [duration / 3600, 3600];
}
if (duration % 60 === 0) {
return [duration / 60, 60];
}
return [duration, 1];
if (duration < 60) {
return [duration, 1];
}
if (duration % 3600 === 0) {
return [duration / 3600, 3600];
}
if (duration % 60 === 0) {
return [duration / 60, 60];
}
return [duration, 1];
}
/**
@ -16,9 +16,9 @@ export function getInterval(duration: number): [number, number] {
* @param ms How many milliseconds to wait
*/
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
return new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
}
export default { getInterval };

View File

@ -1,16 +1,16 @@
import { GetTwitchAuthURL } from '@wailsapp/go/main/App';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
import { GetTwitchAuthURL } from "@wailsapp/go/main/App";
import { BrowserOpenURL } from "@wailsapp/runtime/runtime";
export interface TwitchCredentials {
access_token: string;
refresh_token: string;
expires_in: number;
score: string[];
access_token: string;
refresh_token: string;
expires_in: number;
score: string[];
}
export interface TwitchError {
status: number;
message: string;
status: number;
message: string;
}
/**
@ -21,20 +21,20 @@ export interface TwitchError {
* @throws Credentials are not valid or request failed
*/
export async function twitchAuth(
clientId: string,
clientSecret: string,
clientId: string,
clientSecret: string,
): Promise<TwitchCredentials> {
const url = `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const url = `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const req = await fetch(url, {
method: 'POST',
});
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`authentication failed: ${err.message} (${err.status})'`);
}
const req = await fetch(url, {
method: "POST",
});
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`authentication failed: ${err.message} (${err.status})'`);
}
return req.json() as Promise<TwitchCredentials>;
return req.json() as Promise<TwitchCredentials>;
}
/**
@ -43,22 +43,19 @@ 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> {
const creds = await twitchAuth(clientId, clientSecret);
const req = await fetch('https://api.twitch.tv/helix/streams?first=1', {
headers: {
Authorization: `Bearer ${creds.access_token}`,
'Client-Id': clientId,
},
});
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: {
Authorization: `Bearer ${creds.access_token}`,
"Client-Id": clientId,
},
});
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`API test call failed: ${err.message}`);
}
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`API test call failed: ${err.message}`);
}
}
/**
@ -66,6 +63,6 @@ export async function checkTwitchKeys(
* @param target What's the target of the authentication (stream/chat account)
*/
export async function startAuthFlow(target: string) {
const url = await GetTwitchAuthURL(target);
BrowserOpenURL(url);
const url = await GetTwitchAuthURL(target);
BrowserOpenURL(url);
}

View File

@ -1,465 +1,465 @@
{
"$meta": {
"language-name": "English"
},
"menu": {
"sections": {
"monitor": "Monitor",
"strimertul": "{{APPNAME}}",
"twitch": "Twitch",
"loyalty": "Loyalty system",
"monitor-short": "HOME",
"strimertul-short": "STUL",
"twitch-short": "TWCH",
"loyalty-short": "LOYT"
},
"pages": {
"monitor": {
"dashboard": "Dashboard"
},
"strimertul": {
"settings": "Server settings",
"ui-config": "User interface",
"extensions": "Extensions"
},
"twitch": {
"configuration": "Configuration",
"chat-commands": "Chat commands",
"chat-timers": "Chat timers",
"chat-alerts": "Chat alerts"
},
"loyalty": {
"configuration": "Configuration",
"points": "Points and redeems",
"rewards": "Rewards and goals"
}
},
"messages": {
"update-available": "Update available"
}
},
"pages": {
"http": {
"title": "Server settings",
"bind-placeholder": "address:port",
"bind": "Webserver Bind",
"kilovolt-password": "Kilovolt password",
"kilovolt-placeholder": "Leave empty to disable authentication (not recommended)",
"bind-help": "Every application that uses {{APPNAME}} will need to be updated!",
"static-path": "Static assets (leave empty to disable)",
"static-placeholder": "Absolute path to static assets",
"static-help": "Will be served at the following URL: {{url}}",
"saving": "Saving webserver settings...",
"kv-auth-warning": {
"header": "Are you sure about this?",
"message": "You have left the Kilovolt password field empty! This will leave {{APPNAME}} accessible without authentication from any application, including any website you visit!",
"i-understand": "I understand the risk",
"go-back": "Go back"
}
},
"twitch-settings": {
"title": "Twitch configuration",
"enable": "Enable Twitch integration",
"apiguide-1": "You will need to create an application, here's how:",
"apiguide-2": "Go to <1>https://dev.twitch.tv/console/apps/create</1>",
"apiguide-3": "Use the following data for the required fields:",
"apiguide-4": "Once made, create a <1>New Secret</1>, then copy both fields below and save!",
"app-client-id": "App Client ID",
"app-client-secret": "App Client Secret",
"subtitle": "Twitch integration with streams including chat interactions and API access. If you stream on Twitch, you definitely want this on.",
"api-subheader": "Application info",
"api-configuration": "API access",
"eventsub": "Events",
"chat-settings": "Chat settings",
"chat": {
"header": "Chat settings",
"cooldown-tip": "Global chat cooldown for commands (in seconds)",
"default-user": "Currently using stream account, use the button above to authenticate with a different account.",
"chat-account": "Chat account",
"clear-button": "Revert to default account",
"account-copy": "You can use a different account for repling to chat commands and writing alerts instead of your channel one. To do so, click the button below and authenticate and authorize using your secondary account."
},
"events": {
"loading-data": "Querying user data from Twitch APIs…",
"authenticated-as": "Authenticated as",
"profile-picture": "Profile picture",
"err-no-user": "No twitch user is currently associated",
"sim": {
"channel.update": "Channel update",
"channel.follow": "New follow",
"channel.subscribe": "New sub",
"channel.subscription.gift": "Gift sub",
"channel.subscription.message": "Re-sub with message",
"channel.cheer": "Cheer",
"channel.raid": "Raid"
},
"sim-events": "Send test event",
"auth-button": "Authenticate with Twitch",
"auth-message": "Click the following button to authenticate {{APPNAME}} with your Twitch account:",
"current-status": "Current status"
},
"app-category": "Category",
"app-oauth-redirect-url": "OAuth Redirect URLs",
"test-button": "Test connection",
"test-failed": "Test failed: \"{{error}}\". Check your app client IDs and secret!",
"test-succeeded": "Test succeeded!"
},
"botcommands": {
"title": "Chat commands",
"desc": "Define custom chat commands to set up autoresponders, counters, etc.",
"add-button": "New command",
"search-placeholder": "Search command by name",
"command-header-new": "New command",
"command-header-edit": "Edit command",
"command-name": "Command name",
"command-name-placeholder": "!command",
"command-desc": "Description (optional)",
"command-desc-placeholder": "This command does something",
"command-response": "Response",
"command-response-placeholder": "Hello {0}!",
"command-acl": "Access level",
"command-acl-help": "This specifies the minimum level, eg. if you choose VIPs, moderators and streamer can still use the command",
"response-types": {
"chat": "Message",
"reply": "Reply",
"whisper": "Whisper",
"announce": "Announcement"
},
"acl": {
"everyone": "Everyone",
"subscribers": "Subscribers",
"vip": "VIPs",
"moderators": "Moderators",
"streamer": "Streamer only"
},
"remove-command-title": "Remove command {{name}}?",
"no-commands": "There are no commands configured",
"command-already-in-use": "Command name already in use",
"command-invalid-format": "The response template contains errors"
},
"bottimers": {
"title": "Chat timers",
"desc": "Define reminders such as checking out your social media or ongoing events",
"add-button": "New timer",
"search-placeholder": "Search timer by name",
"timer-header-new": "New timer",
"timer-header-edit": "Edit timer",
"timer-name": "Timer name",
"timer-name-placeholder": "my-timer",
"remove-timer-title": "Remove timer {{name}}?",
"timer-parameters": "every {{time}}, ≥ {{messages}} messages in the last {{interval}}",
"timer-interval": "Minimul interval",
"timer-activity": "Minimul chat activity (0 to disable)",
"timer-activity-desc": "messages in the last 5 minutes",
"timer-messages": "Messages",
"no-timers": "There are no timers configured",
"name-already-in-use": "Timer name already in use"
},
"alerts": {
"title": "Chat alerts",
"desc": "Send chat messages when your viewers follow, subscribe and other events",
"follow-enable": "Enable new follow message",
"messages": "Messages",
"msg-info": "If multiple messages are present, one will be picked at random",
"subscription-enable": "Enable subscription message",
"gift_sub-enable": "Enable gifted subscription message",
"raid-enable": "Enable raid message",
"cheer-enable": "Enable cheering message",
"events": {
"follow": "New follow",
"subscription": "Subscription",
"gift-sub": "Gift sub",
"raid": "Raid",
"cheer": "Cheer"
}
},
"loyalty-settings": {
"title": "Loyalty system configuration",
"subtitle": "Loyalty system allowing viewers to accrue points and spend them on rewards and goals",
"enable": "Enable loyalty system",
"currency-placeholder": "points",
"currency-name": "Currency name",
"currency-name-hint": "This will be appended like this: \"user has X yourcurrency\" so choose a lowercase plural name (ex. points)",
"bonus-points": "Bonus points for active users",
"bonus-points-hint": "Extra amount of points awarded to people who have been chatting in the last set interval",
"note": "Note: Unlike platform-native systems (eg. Twitch channel points), this relies on chat activity rather than actual viewing status.",
"every": "every",
"reward": "How often to give {{currency}}"
},
"loyalty-queue": {
"title": "Points and redeems",
"subtitle": "User leaderboard and pending reward redeems",
"queue-tab": "Redeem queue",
"users-tab": "Manage points",
"username": "Viewer",
"points": "Points",
"username-filter": "Search by username",
"modify-balance-dialog": "Modify balance",
"give-points-dialog": "Give points",
"reward": "Reward",
"date": "Date",
"request": "Request",
"no-redeems": "No pending redeems",
"no-users": "No viewers found",
"refund": "Refund",
"accept": "Accept",
"hardcoded": "My Random hardcoded text"
},
"debug": {
"dismiss-warning": "I am not afraid! ...well ok maybe a little",
"big-ass-warning": "Using this page can severely wreck your database. Please make sure you know what you're doing!",
"disclaimer-header": "Big scary disclaimer",
"title": "Debug ops",
"read-key": "Read DB key",
"write-key": "Write DB key",
"fix-json": "Fix JSON",
"console-ops": "Console operations",
"dump-keys": "Dump all DB keys",
"dump-all": "Dump all KV pairs as JSON"
},
"loyalty-rewards": {
"title": "Rewards and goals",
"rewards-tab": "Rewards",
"goals-tab": "Goals",
"subtitle": "Set up rewards and community goals for your viewers to play with",
"reward-filter": "Search by reward name",
"create-reward": "Create reward",
"reward-id": "Reward ID",
"id-already-in-use": "ID already in use",
"reward-name": "Name",
"reward-icon": "Icon (as remote URL)",
"reward-desc": "Description",
"reward-cost": "Cost",
"reward-id-hint": "This is what viewers will have to write to claim, ie. \"!redeem reward-id-here\".",
"reward-name-hint": "This is what viewers will see they claimed ie. \"USER has claimed REWARDNAME\"",
"reward-cooldown": "Cooldown",
"reward-details-placeholder": "What extra details to ask the viewer for",
"reward-details": "Require viewer details",
"edit-reward": "Edit reward",
"remove-reward-title": "Remove reward \"{{name}}\"?",
"no-rewards": "There are no loyalty rewards, why not start with an Hydrate or Stretch?",
"no-goals": "There are no community goals configured",
"create-goal": "Create goal",
"edit-goal": "Edit goal",
"goal-id": "Goal ID",
"goal-id-hint": "This is what viewers will have to write to contribute to, ie. \"!contribute goal-id-here\".",
"goal-name": "Name",
"goal-icon": "Goal icon (as remote URL)",
"goal-name-hint": "This is what viewers will see they contributed to ie. \"USER has contributed to GOALNAME\"",
"goal-cost": "Total points required",
"goal-desc": "Description",
"goal-filter": "Search by goal name"
},
"strimertul": {
"need-help": "Need help?",
"need-help-p1": "If you need help, want to report a bug or have suggestions on how to make {{APPNAME}} better, please reach out via any of the following channels:",
"license-header": "License",
"license-notice-strimertul": "{{APPNAME}} is licensed under <license>GNU Affero General Public License v3.0</license>",
"credits-header": "Credits",
"credits-renko": "Renko, {{APPNAME}}'s mascot and app icon, was drawn by <artist>Sonic_Chan</artist>"
},
"dashboard": {
"twitch-status": "Twitch stream status",
"live": "Live!",
"x-viewers": "{{num}} viewers",
"not-live": "Offline / Not streaming",
"twitch-events": {
"header": "Recent events",
"warning": "This section only contains events that happened while {{APPNAME}} was open, so only use it for recent stuff",
"anonymous": "An anonymous viewer",
"marker": "Events from previous sessions",
"events": {
"follow": "<n>{{name}}</n> followed you",
"redemption": "<n>{{name}}</n> redeemed <r>{{reward}}</r>",
"stream-start": "You started streaming",
"stream-stop": "You stopped streaming",
"channel-updated": "Stream info changed",
"raided": "<n>{{name}}</n> raided you with <v>{{viewers}} viewers</v>",
"cheered": "<n>{{name}}</n> cheered you with <b>{{bits}} bits</b>",
"subscribed": "<n>{{name}}</n> subscribed to you <t>(Tier {{tier}})</t>",
"subscribed-multi": "<n>{{name}}</n> subscribed to you <m>({{months}} months)</m> <t>(Tier {{tier}})</t>",
"subscrition-gift_one": "<n>{{name}}</n> gifted <c>{{count}}</c> subscription <t>(Tier {{tier}})</t>",
"subscrition-gift_other": "<n>{{name}}</n> gifted <c>{{count}}</c> subscriptions <t>(Tier {{tier}})</t>"
},
"replay": "Replay event"
},
"quick-links": "Useful links",
"link-user-guide": "User guide",
"link-api": "API reference",
"problems": {
"eventsub-scope": "{{APPNAME}} needs new permissions in your Twitch app to work correctly.<br/> Click <a>here</a> to re-authenticate."
}
},
"onboarding": {
"welcome-header": "Welcome to {{APPNAME}}",
"welcome-continue-button": "Get started",
"skip-button": "Skip onboarding",
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
"welcome-p2": "Heads up: if you're used to other tools for streaming, unfortunately this one will require some more work from your end.",
"sections": {
"landing": "Welcome",
"twitch-config": "Twitch integration",
"twitch-events": "Twitch events",
"twitch-bot": "Twitch chat",
"done": "All done!"
},
"twitch-p1": "To set-up Twitch you will need to create an application on the Developer portal. Follow the instructions below or click the button at the bottom to skip this step.",
"twitch-p2": "Click \"Test connection\" to make sure the Client ID and secret are valid, if the test is successful you will be brought to the next step automatically.",
"twitch-skip": "Skip Twitch integration",
"twitch-ev-p1": "Now that you've made an app, you need to authenticate your Twitch account to it so we can access your user data like your channel name or events like new followers or raids.",
"twitch-ev-p3": "If the above shows your account name and picture correctly, you're all set! Click the button below to complete Twitch integration.",
"twitch-complete": "Complete Twitch integration",
"done-header": "You're all set!",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the chat integrations).",
"done-p2": "If you have questions or issues, please reach out at any of these places:",
"done-button": "Complete onboarding",
"done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.",
"welcome-guide": "It might be a good idea to have the {{APPNAME}} user guide open in case you have trouble with any of the following steps, you can open it by <g>clicking here</g>."
},
"uiconfig": {
"title": "User interface settings",
"language": "Language",
"repeat-onboarding": "Repeat onboarding",
"partial-translation": "Partial translation",
"themes": {
"dark": "Dark",
"light": "Light"
},
"theme": "Theme"
},
"extensions": {
"title": "Extensions",
"loading": "Just one second, the extension subsystem is still getting ready!",
"create": "Create new",
"search": "Search by name",
"tab-manage": "Manage",
"tab-editor": "Editor",
"remove-alert": "Remove extension \"{{name}}\"?",
"rename": "Rename extension",
"rename-dialog": "Rename extension \"{{name}}\"",
"name-already-in-use": "Name already in use",
"rename-new-name": "New name",
"format": "Format code",
"statuses": {
"not-ready": "Loading",
"ready": "Not running",
"running": "Running",
"main-loop-finished": "Active",
"error": "Error encountered",
"terminated": "Stopped"
},
"error-alert": "Error details for {{name}}",
"incompatible-body": "This extension requires {{APPNAME}} version {{version}} and up, you are currently running {{appversion}}, which may be too old and miss required features",
"incompatible-warning": "This extension is not compatible"
},
"crash": {
"fatal-message": "A fatal error has occurred and strimertül has stopped working, check the details below:",
"action-header": "What to do?",
"action-submit-line": "Consider submitting this report using the Report button below so someone can look at it.",
"action-recover-line": "If this error happens every time you start the app or if you've been instructed to, click the Recovery button to restore the database to an earlier backup.",
"action-log-line": "Logs and other crash related info can be found in the log files <m>{{A}}</m> and <m>{{B}}</m>.",
"button-report": "Report this error",
"button-recovery": "Recovery options",
"app-log-header": "Application logs for this run",
"report": {
"button-send": "Send report",
"dialog-title": "Report this error",
"thanks-line": "Thanks for choosing to submit this error! If you want, please write below what you were trying to do or anything else that you think might help.",
"additional-label": "Additional info (optional)",
"text-placeholder": "What were you doing before this happened?",
"email-label": "Include email address (if you want to be contacted about this)",
"email-placeholder": "Write your email address here",
"transparency-line": "When clicking \"Send report\", the following info will be collected and sent:",
"transparency-files": "The contents of <m>{{A}}</m> and <m>{{B}}</m>",
"transparency-info": "Information about the error that triggered this crash",
"transparency-user": "The additional info below, if any was provided",
"error-message": "The crash report could not be submitted because of a remote error: {{error}}",
"post-report": "The error was successfully reported and has been assigned the following code: <m>{{code}}</m> If you haven't provided an email and want to follow up on this, use that code when opening an issue or reaching out."
},
"recovery": {
"restore-error": "The database could not be restored because of the following error: {{error}}",
"title": "Recovery options",
"text-head": "These action will irreversibly modify your database, please make sure your database is corrupted in the first place before proceeding.",
"restore-head": "Restore from backup",
"restore-desc-1": "Restore a previously backed up database. This will overwrite your current database with the saved copy. Check below for the list of saved copies.",
"restore-button": "Restore",
"restore-confirm-title": "Confirm database restore",
"restore-confirm-body": "Restoring this backup will overwrite your current database, this operation is irreversible.",
"restore-failed": "Restore failed",
"restore-succeeded-title": "Database restored",
"restore-succeeded-body": "The database was restored from the chosen backup, please close and re-open {{APPNAME}}."
}
},
"interactive-auth": {
"title": "An application is trying to access {{APPNAME}}",
"unknown-name": "Unknown application",
"desc-1": "An application wants access to {{APPNAME}}. Allowing this will make the application capable of interacting and controlling {{APPNAME}}. This includes accessing sensible data stored in the database.",
"warn-1": "Only accept if you know and trust the application.",
"info-present": "The application identified itself as following:",
"verification-code": "As an additional security measure, also verify that the application shows this matching code:",
"allow": "Allow",
"deny": "Deny"
}
},
"form-actions": {
"save": "Save",
"saving": "Saving...",
"saved": "Saved",
"error": "Error",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"cancel": "Cancel",
"ok": "OK",
"add": "Add",
"warning-delete": "This cannot be undone",
"create": "Create",
"submit": "Submit",
"password-reveal": "Reveal",
"password-hide": "Hide",
"start": "Start",
"stop": "Stop",
"rename": "Rename"
},
"debug": {
"dev-build": "Development build"
},
"time": {
"x-hours": "{{time}} hrs",
"x-minutes": "{{time}} min",
"x-seconds": "{{time}} sec",
"hours": "hours",
"minutes": "minutes",
"seconds": "seconds"
},
"pagination": {
"items-per-page": "Items per page",
"page": "Page {{page}}",
"gotopage": "Go to page {{page}}",
"title": "pagination",
"previous": "Previous page",
"next": "Next page",
"gotolast": "Go to last page",
"gotofirst": "Go to first page"
},
"special": {
"wip": {
"header": "WIP - Page under development",
"text": "This page is still under construction, apologies for the lackluster view :("
},
"loading": "{{APPNAME}} is starting up, please wait!"
},
"logging": {
"dialog-title": "Application logs",
"levelFilter": "Filter per log severity",
"level": {
"INFO": "Info",
"WARN": "Warning",
"ERROR": "Error"
},
"copy-to-clipboard": "Copy to clipboard",
"copied": "Copied!",
"toggle-details": "Toggle details"
}
"$meta": {
"language-name": "English"
},
"menu": {
"sections": {
"monitor": "Monitor",
"strimertul": "{{APPNAME}}",
"twitch": "Twitch",
"loyalty": "Loyalty system",
"monitor-short": "HOME",
"strimertul-short": "STUL",
"twitch-short": "TWCH",
"loyalty-short": "LOYT"
},
"pages": {
"monitor": {
"dashboard": "Dashboard"
},
"strimertul": {
"settings": "Server settings",
"ui-config": "User interface",
"extensions": "Extensions"
},
"twitch": {
"configuration": "Configuration",
"chat-commands": "Chat commands",
"chat-timers": "Chat timers",
"chat-alerts": "Chat alerts"
},
"loyalty": {
"configuration": "Configuration",
"points": "Points and redeems",
"rewards": "Rewards and goals"
}
},
"messages": {
"update-available": "Update available"
}
},
"pages": {
"http": {
"title": "Server settings",
"bind-placeholder": "address:port",
"bind": "Webserver Bind",
"kilovolt-password": "Kilovolt password",
"kilovolt-placeholder": "Leave empty to disable authentication (not recommended)",
"bind-help": "Every application that uses {{APPNAME}} will need to be updated!",
"static-path": "Static assets (leave empty to disable)",
"static-placeholder": "Absolute path to static assets",
"static-help": "Will be served at the following URL: {{url}}",
"saving": "Saving webserver settings...",
"kv-auth-warning": {
"header": "Are you sure about this?",
"message": "You have left the Kilovolt password field empty! This will leave {{APPNAME}} accessible without authentication from any application, including any website you visit!",
"i-understand": "I understand the risk",
"go-back": "Go back"
}
},
"twitch-settings": {
"title": "Twitch configuration",
"enable": "Enable Twitch integration",
"apiguide-1": "You will need to create an application, here's how:",
"apiguide-2": "Go to <1>https://dev.twitch.tv/console/apps/create</1>",
"apiguide-3": "Use the following data for the required fields:",
"apiguide-4": "Once made, create a <1>New Secret</1>, then copy both fields below and save!",
"app-client-id": "App Client ID",
"app-client-secret": "App Client Secret",
"subtitle": "Twitch integration with streams including chat interactions and API access. If you stream on Twitch, you definitely want this on.",
"api-subheader": "Application info",
"api-configuration": "API access",
"eventsub": "Events",
"chat-settings": "Chat settings",
"chat": {
"header": "Chat settings",
"cooldown-tip": "Global chat cooldown for commands (in seconds)",
"default-user": "Currently using stream account, use the button above to authenticate with a different account.",
"chat-account": "Chat account",
"clear-button": "Revert to default account",
"account-copy": "You can use a different account for repling to chat commands and writing alerts instead of your channel one. To do so, click the button below and authenticate and authorize using your secondary account."
},
"events": {
"loading-data": "Querying user data from Twitch APIs…",
"authenticated-as": "Authenticated as",
"profile-picture": "Profile picture",
"err-no-user": "No twitch user is currently associated",
"sim": {
"channel.update": "Channel update",
"channel.follow": "New follow",
"channel.subscribe": "New sub",
"channel.subscription.gift": "Gift sub",
"channel.subscription.message": "Re-sub with message",
"channel.cheer": "Cheer",
"channel.raid": "Raid"
},
"sim-events": "Send test event",
"auth-button": "Authenticate with Twitch",
"auth-message": "Click the following button to authenticate {{APPNAME}} with your Twitch account:",
"current-status": "Current status"
},
"app-category": "Category",
"app-oauth-redirect-url": "OAuth Redirect URLs",
"test-button": "Test connection",
"test-failed": "Test failed: \"{{error}}\". Check your app client IDs and secret!",
"test-succeeded": "Test succeeded!"
},
"botcommands": {
"title": "Chat commands",
"desc": "Define custom chat commands to set up autoresponders, counters, etc.",
"add-button": "New command",
"search-placeholder": "Search command by name",
"command-header-new": "New command",
"command-header-edit": "Edit command",
"command-name": "Command name",
"command-name-placeholder": "!command",
"command-desc": "Description (optional)",
"command-desc-placeholder": "This command does something",
"command-response": "Response",
"command-response-placeholder": "Hello {0}!",
"command-acl": "Access level",
"command-acl-help": "This specifies the minimum level, eg. if you choose VIPs, moderators and streamer can still use the command",
"response-types": {
"chat": "Message",
"reply": "Reply",
"whisper": "Whisper",
"announce": "Announcement"
},
"acl": {
"everyone": "Everyone",
"subscribers": "Subscribers",
"vip": "VIPs",
"moderators": "Moderators",
"streamer": "Streamer only"
},
"remove-command-title": "Remove command {{name}}?",
"no-commands": "There are no commands configured",
"command-already-in-use": "Command name already in use",
"command-invalid-format": "The response template contains errors"
},
"bottimers": {
"title": "Chat timers",
"desc": "Define reminders such as checking out your social media or ongoing events",
"add-button": "New timer",
"search-placeholder": "Search timer by name",
"timer-header-new": "New timer",
"timer-header-edit": "Edit timer",
"timer-name": "Timer name",
"timer-name-placeholder": "my-timer",
"remove-timer-title": "Remove timer {{name}}?",
"timer-parameters": "every {{time}}, ≥ {{messages}} messages in the last {{interval}}",
"timer-interval": "Minimul interval",
"timer-activity": "Minimul chat activity (0 to disable)",
"timer-activity-desc": "messages in the last 5 minutes",
"timer-messages": "Messages",
"no-timers": "There are no timers configured",
"name-already-in-use": "Timer name already in use"
},
"alerts": {
"title": "Chat alerts",
"desc": "Send chat messages when your viewers follow, subscribe and other events",
"follow-enable": "Enable new follow message",
"messages": "Messages",
"msg-info": "If multiple messages are present, one will be picked at random",
"subscription-enable": "Enable subscription message",
"gift_sub-enable": "Enable gifted subscription message",
"raid-enable": "Enable raid message",
"cheer-enable": "Enable cheering message",
"events": {
"follow": "New follow",
"subscription": "Subscription",
"gift-sub": "Gift sub",
"raid": "Raid",
"cheer": "Cheer"
}
},
"loyalty-settings": {
"title": "Loyalty system configuration",
"subtitle": "Loyalty system allowing viewers to accrue points and spend them on rewards and goals",
"enable": "Enable loyalty system",
"currency-placeholder": "points",
"currency-name": "Currency name",
"currency-name-hint": "This will be appended like this: \"user has X yourcurrency\" so choose a lowercase plural name (ex. points)",
"bonus-points": "Bonus points for active users",
"bonus-points-hint": "Extra amount of points awarded to people who have been chatting in the last set interval",
"note": "Note: Unlike platform-native systems (eg. Twitch channel points), this relies on chat activity rather than actual viewing status.",
"every": "every",
"reward": "How often to give {{currency}}"
},
"loyalty-queue": {
"title": "Points and redeems",
"subtitle": "User leaderboard and pending reward redeems",
"queue-tab": "Redeem queue",
"users-tab": "Manage points",
"username": "Viewer",
"points": "Points",
"username-filter": "Search by username",
"modify-balance-dialog": "Modify balance",
"give-points-dialog": "Give points",
"reward": "Reward",
"date": "Date",
"request": "Request",
"no-redeems": "No pending redeems",
"no-users": "No viewers found",
"refund": "Refund",
"accept": "Accept",
"hardcoded": "My Random hardcoded text"
},
"debug": {
"dismiss-warning": "I am not afraid! ...well ok maybe a little",
"big-ass-warning": "Using this page can severely wreck your database. Please make sure you know what you're doing!",
"disclaimer-header": "Big scary disclaimer",
"title": "Debug ops",
"read-key": "Read DB key",
"write-key": "Write DB key",
"fix-json": "Fix JSON",
"console-ops": "Console operations",
"dump-keys": "Dump all DB keys",
"dump-all": "Dump all KV pairs as JSON"
},
"loyalty-rewards": {
"title": "Rewards and goals",
"rewards-tab": "Rewards",
"goals-tab": "Goals",
"subtitle": "Set up rewards and community goals for your viewers to play with",
"reward-filter": "Search by reward name",
"create-reward": "Create reward",
"reward-id": "Reward ID",
"id-already-in-use": "ID already in use",
"reward-name": "Name",
"reward-icon": "Icon (as remote URL)",
"reward-desc": "Description",
"reward-cost": "Cost",
"reward-id-hint": "This is what viewers will have to write to claim, ie. \"!redeem reward-id-here\".",
"reward-name-hint": "This is what viewers will see they claimed ie. \"USER has claimed REWARDNAME\"",
"reward-cooldown": "Cooldown",
"reward-details-placeholder": "What extra details to ask the viewer for",
"reward-details": "Require viewer details",
"edit-reward": "Edit reward",
"remove-reward-title": "Remove reward \"{{name}}\"?",
"no-rewards": "There are no loyalty rewards, why not start with an Hydrate or Stretch?",
"no-goals": "There are no community goals configured",
"create-goal": "Create goal",
"edit-goal": "Edit goal",
"goal-id": "Goal ID",
"goal-id-hint": "This is what viewers will have to write to contribute to, ie. \"!contribute goal-id-here\".",
"goal-name": "Name",
"goal-icon": "Goal icon (as remote URL)",
"goal-name-hint": "This is what viewers will see they contributed to ie. \"USER has contributed to GOALNAME\"",
"goal-cost": "Total points required",
"goal-desc": "Description",
"goal-filter": "Search by goal name"
},
"strimertul": {
"need-help": "Need help?",
"need-help-p1": "If you need help, want to report a bug or have suggestions on how to make {{APPNAME}} better, please reach out via any of the following channels:",
"license-header": "License",
"license-notice-strimertul": "{{APPNAME}} is licensed under <license>GNU Affero General Public License v3.0</license>",
"credits-header": "Credits",
"credits-renko": "Renko, {{APPNAME}}'s mascot and app icon, was drawn by <artist>Sonic_Chan</artist>"
},
"dashboard": {
"twitch-status": "Twitch stream status",
"live": "Live!",
"x-viewers": "{{num}} viewers",
"not-live": "Offline / Not streaming",
"twitch-events": {
"header": "Recent events",
"warning": "This section only contains events that happened while {{APPNAME}} was open, so only use it for recent stuff",
"anonymous": "An anonymous viewer",
"marker": "Events from previous sessions",
"events": {
"follow": "<n>{{name}}</n> followed you",
"redemption": "<n>{{name}}</n> redeemed <r>{{reward}}</r>",
"stream-start": "You started streaming",
"stream-stop": "You stopped streaming",
"channel-updated": "Stream info changed",
"raided": "<n>{{name}}</n> raided you with <v>{{viewers}} viewers</v>",
"cheered": "<n>{{name}}</n> cheered you with <b>{{bits}} bits</b>",
"subscribed": "<n>{{name}}</n> subscribed to you <t>(Tier {{tier}})</t>",
"subscribed-multi": "<n>{{name}}</n> subscribed to you <m>({{months}} months)</m> <t>(Tier {{tier}})</t>",
"subscrition-gift_one": "<n>{{name}}</n> gifted <c>{{count}}</c> subscription <t>(Tier {{tier}})</t>",
"subscrition-gift_other": "<n>{{name}}</n> gifted <c>{{count}}</c> subscriptions <t>(Tier {{tier}})</t>"
},
"replay": "Replay event"
},
"quick-links": "Useful links",
"link-user-guide": "User guide",
"link-api": "API reference",
"problems": {
"eventsub-scope": "{{APPNAME}} needs new permissions in your Twitch app to work correctly.<br/> Click <a>here</a> to re-authenticate."
}
},
"onboarding": {
"welcome-header": "Welcome to {{APPNAME}}",
"welcome-continue-button": "Get started",
"skip-button": "Skip onboarding",
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
"welcome-p2": "Heads up: if you're used to other tools for streaming, unfortunately this one will require some more work from your end.",
"sections": {
"landing": "Welcome",
"twitch-config": "Twitch integration",
"twitch-events": "Twitch events",
"twitch-bot": "Twitch chat",
"done": "All done!"
},
"twitch-p1": "To set-up Twitch you will need to create an application on the Developer portal. Follow the instructions below or click the button at the bottom to skip this step.",
"twitch-p2": "Click \"Test connection\" to make sure the Client ID and secret are valid, if the test is successful you will be brought to the next step automatically.",
"twitch-skip": "Skip Twitch integration",
"twitch-ev-p1": "Now that you've made an app, you need to authenticate your Twitch account to it so we can access your user data like your channel name or events like new followers or raids.",
"twitch-ev-p3": "If the above shows your account name and picture correctly, you're all set! Click the button below to complete Twitch integration.",
"twitch-complete": "Complete Twitch integration",
"done-header": "You're all set!",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the chat integrations).",
"done-p2": "If you have questions or issues, please reach out at any of these places:",
"done-button": "Complete onboarding",
"done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.",
"welcome-guide": "It might be a good idea to have the {{APPNAME}} user guide open in case you have trouble with any of the following steps, you can open it by <g>clicking here</g>."
},
"uiconfig": {
"title": "User interface settings",
"language": "Language",
"repeat-onboarding": "Repeat onboarding",
"partial-translation": "Partial translation",
"themes": {
"dark": "Dark",
"light": "Light"
},
"theme": "Theme"
},
"extensions": {
"title": "Extensions",
"loading": "Just one second, the extension subsystem is still getting ready!",
"create": "Create new",
"search": "Search by name",
"tab-manage": "Manage",
"tab-editor": "Editor",
"remove-alert": "Remove extension \"{{name}}\"?",
"rename": "Rename extension",
"rename-dialog": "Rename extension \"{{name}}\"",
"name-already-in-use": "Name already in use",
"rename-new-name": "New name",
"format": "Format code",
"statuses": {
"not-ready": "Loading",
"ready": "Not running",
"running": "Running",
"main-loop-finished": "Active",
"error": "Error encountered",
"terminated": "Stopped"
},
"error-alert": "Error details for {{name}}",
"incompatible-body": "This extension requires {{APPNAME}} version {{version}} and up, you are currently running {{appversion}}, which may be too old and miss required features",
"incompatible-warning": "This extension is not compatible"
},
"crash": {
"fatal-message": "A fatal error has occurred and strimertül has stopped working, check the details below:",
"action-header": "What to do?",
"action-submit-line": "Consider submitting this report using the Report button below so someone can look at it.",
"action-recover-line": "If this error happens every time you start the app or if you've been instructed to, click the Recovery button to restore the database to an earlier backup.",
"action-log-line": "Logs and other crash related info can be found in the log files <m>{{A}}</m> and <m>{{B}}</m>.",
"button-report": "Report this error",
"button-recovery": "Recovery options",
"app-log-header": "Application logs for this run",
"report": {
"button-send": "Send report",
"dialog-title": "Report this error",
"thanks-line": "Thanks for choosing to submit this error! If you want, please write below what you were trying to do or anything else that you think might help.",
"additional-label": "Additional info (optional)",
"text-placeholder": "What were you doing before this happened?",
"email-label": "Include email address (if you want to be contacted about this)",
"email-placeholder": "Write your email address here",
"transparency-line": "When clicking \"Send report\", the following info will be collected and sent:",
"transparency-files": "The contents of <m>{{A}}</m> and <m>{{B}}</m>",
"transparency-info": "Information about the error that triggered this crash",
"transparency-user": "The additional info below, if any was provided",
"error-message": "The crash report could not be submitted because of a remote error: {{error}}",
"post-report": "The error was successfully reported and has been assigned the following code: <m>{{code}}</m> If you haven't provided an email and want to follow up on this, use that code when opening an issue or reaching out."
},
"recovery": {
"restore-error": "The database could not be restored because of the following error: {{error}}",
"title": "Recovery options",
"text-head": "These action will irreversibly modify your database, please make sure your database is corrupted in the first place before proceeding.",
"restore-head": "Restore from backup",
"restore-desc-1": "Restore a previously backed up database. This will overwrite your current database with the saved copy. Check below for the list of saved copies.",
"restore-button": "Restore",
"restore-confirm-title": "Confirm database restore",
"restore-confirm-body": "Restoring this backup will overwrite your current database, this operation is irreversible.",
"restore-failed": "Restore failed",
"restore-succeeded-title": "Database restored",
"restore-succeeded-body": "The database was restored from the chosen backup, please close and re-open {{APPNAME}}."
}
},
"interactive-auth": {
"title": "An application is trying to access {{APPNAME}}",
"unknown-name": "Unknown application",
"desc-1": "An application wants access to {{APPNAME}}. Allowing this will make the application capable of interacting and controlling {{APPNAME}}. This includes accessing sensible data stored in the database.",
"warn-1": "Only accept if you know and trust the application.",
"info-present": "The application identified itself as following:",
"verification-code": "As an additional security measure, also verify that the application shows this matching code:",
"allow": "Allow",
"deny": "Deny"
}
},
"form-actions": {
"save": "Save",
"saving": "Saving...",
"saved": "Saved",
"error": "Error",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"cancel": "Cancel",
"ok": "OK",
"add": "Add",
"warning-delete": "This cannot be undone",
"create": "Create",
"submit": "Submit",
"password-reveal": "Reveal",
"password-hide": "Hide",
"start": "Start",
"stop": "Stop",
"rename": "Rename"
},
"debug": {
"dev-build": "Development build"
},
"time": {
"x-hours": "{{time}} hrs",
"x-minutes": "{{time}} min",
"x-seconds": "{{time}} sec",
"hours": "hours",
"minutes": "minutes",
"seconds": "seconds"
},
"pagination": {
"items-per-page": "Items per page",
"page": "Page {{page}}",
"gotopage": "Go to page {{page}}",
"title": "pagination",
"previous": "Previous page",
"next": "Next page",
"gotolast": "Go to last page",
"gotofirst": "Go to first page"
},
"special": {
"wip": {
"header": "WIP - Page under development",
"text": "This page is still under construction, apologies for the lackluster view :("
},
"loading": "{{APPNAME}} is starting up, please wait!"
},
"logging": {
"dialog-title": "Application logs",
"levelFilter": "Filter per log severity",
"level": {
"INFO": "Info",
"WARN": "Warning",
"ERROR": "Error"
},
"copy-to-clipboard": "Copy to clipboard",
"copied": "Copied!",
"toggle-details": "Toggle details"
}
}

View File

@ -1,465 +1,465 @@
{
"$meta": {
"language-name": "Italiano"
},
"form-actions": {
"save": "Salva",
"saving": "Sto salvando...",
"error": "Errore",
"saved": "Salvato",
"cancel": "Annulla",
"ok": "OK",
"add": "Aggiungi",
"edit": "Modifica",
"delete": "Elimina",
"warning-delete": "Questa operazione è irreversibile",
"create": "Crea",
"enable": "Abilita",
"disable": "Disabilita",
"submit": "Invia",
"password-hide": "Nascondi",
"password-reveal": "Mostra",
"rename": "Rinomina",
"start": "Avvia",
"stop": "Ferma"
},
"special": {
"wip": {
"header": "WIP - Pagina non pronta",
"text": "Questa pagina è ancora in lavorazione, chiedo venia per la vista scarna :("
},
"loading": "{{APPNAME}} si sta avviando, un attimo di pazienza..."
},
"logging": {
"dialog-title": "Log applicazione",
"copied": "Copiato!",
"copy-to-clipboard": "Copia negli appunti",
"levelFilter": "Filtra per livello",
"level": {
"ERROR": "Errore",
"INFO": "Info",
"WARN": "Avvertimento"
},
"toggle-details": "Mostra dettagli"
},
"pagination": {
"title": "paginazione",
"previous": "Pagina precedente",
"next": "Pagina successiva",
"items-per-page": "Elementi per pagina",
"page": "Pagina {{page}}",
"gotofirst": "Vai alla prima pagina",
"gotopage": "Vai a pagina {{page}}",
"gotolast": "Vai all'ultima pagina"
},
"debug": {
"dev-build": "Build di sviluppo"
},
"menu": {
"messages": {
"update-available": "Aggiornamento disponibile"
},
"pages": {
"loyalty": {
"configuration": "Configurazione",
"points": "Punti e ricompense",
"rewards": "Ricompense e obiettivi"
},
"monitor": {
"dashboard": "Vista generale"
},
"strimertul": {
"settings": "Impostazioni server",
"ui-config": "Opzioni interfaccia",
"extensions": "Estensioni"
},
"twitch": {
"chat-alerts": "Avvisi in chat",
"chat-commands": "Comandi chat",
"chat-timers": "Timer chat",
"configuration": "Configurazione"
}
},
"sections": {
"loyalty": "Punti fedeltà",
"monitor": "Monitor",
"strimertul": "strimertul",
"twitch": "Twitch",
"monitor-short": "PANL",
"strimertul-short": "STUL",
"twitch-short": "TWCH",
"loyalty-short": "PNTI"
}
},
"pages": {
"botcommands": {
"remove-command-title": "Rimuovere il comando {{name}}?",
"command-name": "Nome comando",
"command-name-placeholder": "!comando",
"command-desc": "Descrizione (opzionale)",
"command-desc-placeholder": "Questo comando fa qualcosa",
"command-response": "Risposta",
"command-response-placeholder": "Ciao {0}!",
"command-acl": "Livello d'accesso richiesto",
"command-acl-help": "Specifica il livello minimo richiesto, ad esempio se scegli VIP, sia VIP che moderatori che lo streamer potranno usare il comando",
"title": "Comandi del bot",
"desc": "Crea comandi chat personalizzati per autorisponditori, contatori, ecc.",
"add-button": "Crea comando",
"search-placeholder": "Cerca comando per nome",
"no-commands": "Il bot non ha comandi configurati",
"command-header-new": "Nuovo comando",
"command-header-edit": "Modifica comando",
"acl": {
"everyone": "Chiunque",
"moderators": "Moderatori",
"streamer": "Solo streamer",
"subscribers": "Abbonati",
"vip": "VIP"
},
"command-already-in-use": "Nome comando già in uso",
"response-types": {
"announce": "Annuncio",
"chat": "Canale",
"reply": "Risposta",
"whisper": "Chat privata"
},
"command-invalid-format": "Il messaggio contiene errori"
},
"bottimers": {
"add-button": "Nuovo timer",
"desc": "Definisci promemoria tipo seguire i tuoi social o eventi in corso",
"no-timers": "Non ci sono timer configurati",
"remove-timer-title": "Rimuovere il timer {{timer}}?",
"search-placeholder": "Cerca timer per nome",
"timer-activity": "Attività in chat minima (0 per disabilitare)",
"timer-activity-desc": "messaggi negli ultimi 5 minuti",
"timer-header-edit": "Modifica timer",
"timer-header-new": "Nuovo timer",
"timer-interval": "Intervallo minimo",
"timer-messages": "Messaggi",
"timer-name": "Nome timer",
"timer-name-placeholder": "mio-timer",
"timer-parameters": "ogni {{time}}, ≥ {{messages}} messaggi negli ultimi {{interval}}",
"title": "Timer del bot",
"name-already-in-use": "Nome timer già in uso"
},
"dashboard": {
"live": "In onda!",
"not-live": "Offline / Non in streaming",
"twitch-status": "Stato stream Twitch",
"x-viewers": "{{num}} spettatori",
"twitch-events": {
"anonymous": "Uno spettatore anonimo",
"header": "Eventi recenti",
"warning": "Questa sezione contiene solo gli eventi accaduti mentre {{APPNAME}} era aperto, quindi utilizzala solo per cose recenti",
"marker": "Eventi delle sessioni precedenti",
"events": {
"channel-updated": "Informazioni stream modificate",
"cheered": "<n>{{name}}</n> ti ha tifato con <b>{{bits}} bit</b>",
"follow": "<n>{{name}}</n> ti ha seguito",
"raided": "<n>{{name}}</n> ti ha fatto un raid con <v>{{viewers}} spettatori</v>",
"redemption": "<n>{{name}}</n> ha riscattato <r>{{reward}}</r>",
"stream-start": "Hai iniziato un nuovo stream",
"stream-stop": "Hai chiuso lo stream",
"subscribed": "<n>{{name}}</n> si è abbonato <t>(Livello {{tier}})</t>",
"subscribed-multi": "<n>{{name}}</n> si è abbonato <m>({{months}} mesi)</m> <t>(Livello {{tier}})</t>",
"subscrition-gift_one": "<n>{{name}}</n> ha regalato <c>{{count}}</c> abbonamento <t>(Livello {{tier}})</t>",
"subscrition-gift_other": "<n>{{name}}</n> ha regalato <c>{{count}}</c> abbonamenti <t>(Livello {{tier}})</t>"
},
"replay": "Ripeti evento"
},
"link-api": "Documentazione API",
"link-user-guide": "Guida utente",
"quick-links": "Link utili",
"problems": {
"eventsub-scope": "{{APPNAME}} necessita di nuove autorizzazioni nella tua app Twitch per funzionare correttamente.<br/> Fai clic <a>qui</a> per autenticarti nuovamente."
}
},
"debug": {
"big-ass-warning": "L'utilizzo di questa pagina può danneggiare gravemente il tuo database. \nSpero tu sappia cosa stai facendo!",
"console-ops": "Operazioni su console",
"disclaimer-header": "Scritta gigante spaventosa",
"dismiss-warning": "Non ho paura! \n...beh ok forse un po' sì",
"dump-all": "Stampa tutti i valori KV come JSON",
"dump-keys": "Stampa tutte le chiavi a DB",
"fix-json": "Correggi JSON",
"read-key": "Leggi chiave DB",
"title": "Operazioni di debug",
"write-key": "Scrivi chiave DB"
},
"alerts": {
"cheer-enable": "Abilita messaggio per bit",
"desc": "Invia messaggi in chat quando i tuoi spettatori seguono, si abbonano o altri eventi",
"follow-enable": "Abilita messaggio per nuovo follower",
"gift_sub-enable": "Abilita messaggio per regalo abbonamento",
"messages": "Messaggi",
"msg-info": "Se sono presenti più messaggi, ne verrà scelto uno a caso",
"raid-enable": "Abilita messaggio per raid",
"subscription-enable": "Abilita messaggio per abbonamenti",
"title": "Avvisi in chat",
"events": {
"cheer": "Bit",
"follow": "Nuovo follower",
"gift-sub": "Regalo abbonamento",
"raid": "Raid",
"subscription": "Abbonamento"
}
},
"http": {
"bind": "Indirizzo/porta server",
"bind-help": "Ogni applicazione che utilizza {{APPNAME}} dovrà essere aggiornata!",
"bind-placeholder": "indirizzo:porta",
"kilovolt-password": "Password kilovolt",
"kilovolt-placeholder": "Lascia vuoto per disabilitare l'autenticazione (non consigliato)",
"saving": "Salvataggio delle impostazioni del server...",
"static-help": "Sarà disponibile al seguente URL: {{url}}",
"static-path": "Risorse statiche (lasciare vuoto per disabilitare)",
"static-placeholder": "Percorso completo alle risorse statiche",
"title": "Impostazioni del server",
"kv-auth-warning": {
"go-back": "Torna indietro",
"header": "Sei sicuro di quello che stai facendo?",
"i-understand": "Accetto il rischio",
"message": "Hai lasciato vuoto il campo della password Kilovolt! \nQuesto lascerà {{APPNAME}} accessibile senza autenticazione da qualsiasi applicazione, incluso qualsiasi sito web che visiti!"
}
},
"loyalty-queue": {
"accept": "Accetta",
"date": "Data",
"give-points-dialog": "Dai punti",
"hardcoded": "Testo a caso qui",
"modify-balance-dialog": "Modifica bilancio",
"no-redeems": "Nessun riscatto in sospeso",
"no-users": "Nessun spettatore trovato",
"points": "Punti",
"queue-tab": "Coda riscatti",
"refund": "Rimborsa",
"request": "Richiesta",
"reward": "Ricompensa",
"title": "Punti e ricompense",
"subtitle": "Classifica punti e ricompense in attesa",
"username": "Spettatore",
"username-filter": "Cerca per nome utente",
"users-tab": "Gestisci punti"
},
"loyalty-rewards": {
"create-goal": "Crea obiettivo",
"create-reward": "Crea ricompensa",
"edit-goal": "Modifica obiettivo",
"edit-reward": "Modifica ricompensa",
"goal-cost": "Punti totali richiesti",
"goal-desc": "Descrizione",
"goal-filter": "Cerca per nome obiettivo",
"goal-icon": "Icona obiettivo (URL)",
"goal-id": "ID obiettivo",
"goal-id-hint": "Questo è ciò che gli spettatori dovranno scrivere per contribuire, ad es. \n\"!contribuisci id-obiettivo-qui\".",
"goal-name": "Nome",
"goal-name-hint": "Questo è ciò che gli spettatori vedranno quando contribuiranno, ad es. \n\"SPETTATORE ha contribuito a NOMEOBIETTIVO\"",
"goals-tab": "Obiettivi",
"id-already-in-use": "ID già in uso",
"no-goals": "Non ci sono obiettivi configurati",
"no-rewards": "Non ci sono ricompense, perché non iniziare con un \"Bevi\" o \"Fai stretching\"?",
"remove-reward-title": "Rimuovere ricompensa \"{{name}}\"?",
"reward-cooldown": "Tempo di ricarica",
"reward-cost": "Prezzo",
"reward-desc": "Descrizione",
"reward-details": "Richiedi dettagli",
"reward-details-placeholder": "Quali dettagli extra chiedere allo spettatore",
"reward-filter": "Cerca per nome ricompensa",
"reward-icon": "Icona (URL)",
"reward-id": "ID ricompensa",
"reward-id-hint": "Questo è ciò che gli spettatori dovranno scrivere per riscattare, ad es. \n\"!redeem id-premio-qui\".",
"reward-name": "Nome",
"reward-name-hint": "Questo è ciò che gli spettatori vedranno quando riscatteranno, ad es. \n\"SPETTATORE ha riscattato NOMERICOMPENSA\"",
"rewards-tab": "Ricompense",
"subtitle": "Imposta ricompense ed obiettivi della community con cui i tuoi spettatori possono interagire",
"title": "Ricompense e obiettivi"
},
"loyalty-settings": {
"bonus-points": "Punti bonus per i spettatori attivi",
"bonus-points-hint": "Quantità extra di punti assegnati alle persone che hanno chattato nell'ultimo intervallo impostato",
"currency-name": "Nome dei punti",
"currency-name-hint": "Questo verrà usato in questo modo: \"persona ha X nomequi\" quindi scegli un nome plurale minuscolo (es. punti)",
"currency-placeholder": "punti",
"enable": "Abilita punti fedeltà",
"every": "ogni",
"note": "Nota: a differenza di come funziona nelle piattaforme (ad es. Punti canale Twitch), questo si basa sull'attività in chat piuttosto che sullo stato effettivo di visualizzazione.",
"reward": "Quanto spesso dare {{currency}}",
"subtitle": "Punti fedeltà che consentono agli spettatori di accumulare punti e spenderli in ricompense e obiettivi",
"title": "Configurazione punti fedeltà"
},
"onboarding": {
"skip-button": "Salta procedura guidata",
"welcome-continue-button": "Cominciamo",
"welcome-header": "Benvenuto su {{APPNAME}}",
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento.",
"welcome-p2": "Giusto una cosa: se sei abituato ad altri strumenti di questo tipo, stavolta toccherà un po' più lavoro da parte tua!",
"sections": {
"done": "Pronti a partire!",
"landing": "Benvenuto",
"twitch-bot": "Bot per Twitch",
"twitch-config": "Integrazione Twitch",
"twitch-events": "Eventi Twitch"
},
"done-button": "Completa procedura guidata",
"done-p1": "Dovremmo esserci per ora. \nPuoi sempre modificare qualsiasi opzione in un secondo momento, comprese configurazioni personalizzate non trattate in questa procedura (ad esempio utilizzare un account Twitch diverso per il bot).",
"done-p3": "Fai clic sul pulsante qui sotto per completare questa procedura ed andare nella dashboard di {{APPNAME}}.",
"twitch-complete": "Completa integrazione Twitch",
"twitch-ev-p3": "Se vedi qui sopra il nome e l'immagine profilo del tuo account, sei a posto! \nFai clic sul pulsante in basso per completare l'integrazione con Twitch.",
"twitch-p2": "Fai clic su \"Test connessione\" per assicurarti che l'ID client e il segreto specificati siano validi, se il test ha esito positivo verrai portato automaticamente al passaggio successivo.",
"twitch-skip": "Salta integrazione con Twitch",
"twitch-p1": "Per configurare Twitch dovrai creare un'applicazione sul portale per gli sviluppatori. Segui le istruzioni di seguito o fai clic sul pulsante in basso per saltare questo passaggio.",
"twitch-ev-p1": "Ora che hai creato un'app, devi autenticarci il tuo account Twitch in modo che possiamo accedere a dati come il nome del tuo canale o eventi come nuovi follower o raid.",
"done-p2": "In caso di domande o problemi, contattaci in uno di questi modi:",
"done-header": "È tutto pronto!",
"welcome-guide": "Sarebbe una buona idea tenere aperta la guida utente di {{APPNAME}} nel caso incontrassi difficoltà con uno dei seguenti passaggi, puoi aprirla <g>cliccando qui</g>."
},
"strimertul": {
"credits-header": "Ringraziamenti",
"credits-renko": "Renko, mascotte e icona di {{APPNAME}}, è stata disegnata da <artist>Sonic_Chan</artist>",
"license-header": "Licenza",
"license-notice-strimertul": "{{APPNAME}} è concesso sotto licenza <license>GNU Affero General Public License v3.0</license>",
"need-help": "Hai bisogno di aiuto?",
"need-help-p1": "Se hai bisogno di aiuto, vuoi segnalare un bug o hai suggerimenti su come migliorare {{APPNAME}}, contattaci tramite uno dei seguenti canali:"
},
"twitch-settings": {
"api-configuration": "Accesso API",
"api-subheader": "Info applicazione",
"apiguide-1": "Dovrai creare un'applicazione, ecco come:",
"apiguide-2": "Vai su <1>https://dev.twitch.tv/console/apps/create</1>",
"apiguide-3": "Utilizza i seguenti dati per i campi obbligatori:",
"apiguide-4": "Una volta creato, crea un <1>Nuovo segreto</1>, quindi copia entrambi i campi qui sotto e salva!",
"app-client-id": "ID client",
"app-category": "Categoria",
"app-client-secret": "Segreto client",
"app-oauth-redirect-url": "Reindirizzamento URL OAuth",
"chat-settings": "Impostazioni chat",
"enable": "Abilita integrazione Twitch",
"eventsub": "Eventi",
"subtitle": "Integrazione con stream su Twitch, incluso chat bot e accesso API. \nSe usi Twitch come piattaforma di streaming, lo vorrai sicuramente.",
"title": "Configurazione Twitch",
"chat": {
"cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)",
"chat-account": "Account chat",
"header": "Impostazioni chat",
"default-user": "Utilizzando l'account principale, usa il pulsante qui sopra per autenticarti con un account diverso per le funzionalità di chat.",
"clear-button": "Torna ad usare l'account principale",
"account-copy": "Puoi utilizzare un account diverso per rispondendere ai comandi della chat e invia notifiche al posto di quello del tuo canale. \nPer fare ciò, fai clic sul pulsante in basso e autentica e autorizza l'utilizzo del tuo account secondario."
},
"events": {
"auth-button": "Autenticati via Twitch",
"auth-message": "Fai clic sul pulsante qui sotto per autorizzare {{APPNAME}} ad accedere a notifiche del tuo account Twitch:",
"authenticated-as": "Autenticato come",
"current-status": "Stato attuale",
"err-no-user": "Nessun utente twitch è attualmente associato",
"loading-data": "Sto chiedendo i dati utente a Twitch...",
"profile-picture": "Immagine del profilo",
"sim-events": "Invia evento di prova",
"sim": {
"channel.update": "Aggiornamento canale",
"channel.follow": "Nuovo follower",
"channel.subscribe": "Nuovo abbonato",
"channel.subscription.gift": "Regalo abbonamento",
"channel.subscription.message": "Abbonamento con messaggio",
"channel.cheer": "Tifo",
"channel.raid": "Raid"
}
},
"test-button": "Test connessione",
"test-failed": "Test fallito: \"{{error}}\". \nControlla ID e segreto client dell'app!",
"test-succeeded": "Test riuscito!"
},
"uiconfig": {
"language": "Lingua",
"partial-translation": "Traduzione parziale",
"repeat-onboarding": "Ripeti procudura di configurazione",
"title": "Impostazioni interfaccia utente",
"theme": "Tema",
"themes": {
"dark": "Scuro",
"light": "Chiaro"
}
},
"extensions": {
"create": "Crea nuovo",
"loading": "Solo un secondo, il sistema di estensioni si sta ancora preparando!",
"remove-alert": "Rimuovere l'estensione \"{{name}}\"?",
"format": "Formatta codice",
"name-already-in-use": "Nome già in uso",
"rename": "Rinomina estensione",
"rename-dialog": "Rinominare l'estensione \"{{name}}\"",
"rename-new-name": "Nuovo nome",
"search": "Cerca per nome",
"statuses": {
"error": "Errore riscontrato",
"main-loop-finished": "Attivo",
"not-ready": "Caricamento in corso",
"ready": "Non attivo",
"running": "In esecuzione",
"terminated": "Fermato"
},
"tab-editor": "Editor",
"tab-manage": "Gestisci",
"title": "Estensioni",
"error-alert": "Dettagli errore per {{name}}",
"incompatible-body": "Questa estensione richiede {{APPNAME}} versione {{version}} o successive, al momento stai utilizzando {{appversion}} che potrebbe essere troppo vecchia e perciò senza alcune funzionalità richieste",
"incompatible-warning": "Questa estensione non è compatibile"
},
"crash": {
"action-header": "Che fare?",
"action-recover-line": "Se questo errore si verifica ogni volta che avvii l'app o se ti è stato richiesto di farlo, fai clic sul pulsante Ripristino per recuperare il database da un backup precedente.",
"action-log-line": "I log e altre informazioni relative agli arresti anomali sono disponibili nei file di log <m>{{A}}</m> e <m>{{B}}</m>.",
"action-submit-line": "Puoi inviare una segnalazione di questo arresto anomalo utilizzando il pulsante Segnala in basso in modo che qualcuno possa esaminarla.",
"app-log-header": "Log applicazione per questa esecuzione",
"button-recovery": "Menù ripristino",
"button-report": "Segnala questo errore",
"fatal-message": "Si è verificato un errore irreversibile e strimertül ha smesso di funzionare, ecco i dettagli:",
"report": {
"additional-label": "Info aggiuntive (opzionale)",
"button-send": "Invia segnalazione",
"dialog-title": "Segnala questo errore",
"text-placeholder": "Cosa stavi facendo nell'applicazione?",
"thanks-line": "Grazie per aver scelto di segnalare questo errore! \nSe vuoi, scrivi qui sotto cosa stavi cercando di fare o qualsiasi altra cosa che pensi possa essere d'aiuto.",
"email-label": "Includi una email (se vuoi essere contattato in merito)",
"email-placeholder": "Scrivi qui il tuo indirizzo email",
"transparency-files": "I contenuti di <m>{{A}}</m> e <m>{{B}}</m>",
"transparency-info": "Informazioni sull'errore specifico che ha causato il crash",
"transparency-line": "Facendo clic su \"Invia segnalazione\", verranno raccolte e inviate i seguenti dati:",
"transparency-user": "Le informazioni aggiuntive di seguito, se fornite",
"error-message": "Non è stato possibile inviare la segnalazione di errore a causa di un errore remoto: {{error}}",
"post-report": "L'errore è stato segnalato con successo ed è stato assegnato il seguente codice: <m>{{code}}</m> Se non hai fornito un'e-mail e vuoi contattarci in merito, usa quel codice quando apri una segnalazione o altro contatto."
},
"recovery": {
"restore-button": "Ripristina",
"restore-confirm-body": "Il ripristino di questo backup sovrascriverà il database attuale, questa operazione è irreversibile.",
"restore-confirm-title": "Conferma ripristino del database",
"restore-desc-1": "Ripristina il database utilizzando un backup creato precedentemente. \nQuesto sovrascriverà il database attuale con la copia salvata. \nQui di seguito è la lista di tutti i backup attualmente salvati.",
"restore-error": "Impossibile ripristinare il database a causa del seguente errore: {{error}}",
"restore-failed": "Ripristino non riuscito",
"restore-head": "Ripristina dal backup",
"text-head": "Queste azioni modificheranno irreversibilmente il database, assicurati che il database sia effettivamente danneggiato prima di procedere.",
"title": "Opzioni di ripristino",
"restore-succeeded-title": "Database ripristinato",
"restore-succeeded-body": "Il database è stato ripristinato dal backup scelto, chiudi e riapri {{APPNAME}}."
}
},
"interactive-auth": {
"allow": "Consenti",
"deny": "Nega",
"title": "Un'applicazione sta tentando di accedere a {{APPNAME}}",
"unknown-name": "Applicazione sconosciuta",
"verification-code": "Come ulteriore misura di sicurezza, verifica anche che l'applicazione mostri questo codice:",
"warn-1": "Consenti solo se conosci e ti fidi dell'applicazione.",
"desc-1": "Un'applicazione vuole accedere a {{APPNAME}}. \nConsentire ciò renderà l'applicazione in grado di interagire e controllare {{APPNAME}}. \nCiò include l'accesso a dati sensibili contenuti nel database.",
"info-present": "L'applicazione si identifica come di seguito:"
}
},
"time": {
"hours": "ore",
"minutes": "minuti",
"seconds": "secondi",
"x-hours": "{{time}} ore",
"x-minutes": "{{tempo}} min",
"x-seconds": "{{time}} sec"
}
"$meta": {
"language-name": "Italiano"
},
"form-actions": {
"save": "Salva",
"saving": "Sto salvando...",
"error": "Errore",
"saved": "Salvato",
"cancel": "Annulla",
"ok": "OK",
"add": "Aggiungi",
"edit": "Modifica",
"delete": "Elimina",
"warning-delete": "Questa operazione è irreversibile",
"create": "Crea",
"enable": "Abilita",
"disable": "Disabilita",
"submit": "Invia",
"password-hide": "Nascondi",
"password-reveal": "Mostra",
"rename": "Rinomina",
"start": "Avvia",
"stop": "Ferma"
},
"special": {
"wip": {
"header": "WIP - Pagina non pronta",
"text": "Questa pagina è ancora in lavorazione, chiedo venia per la vista scarna :("
},
"loading": "{{APPNAME}} si sta avviando, un attimo di pazienza..."
},
"logging": {
"dialog-title": "Log applicazione",
"copied": "Copiato!",
"copy-to-clipboard": "Copia negli appunti",
"levelFilter": "Filtra per livello",
"level": {
"ERROR": "Errore",
"INFO": "Info",
"WARN": "Avvertimento"
},
"toggle-details": "Mostra dettagli"
},
"pagination": {
"title": "paginazione",
"previous": "Pagina precedente",
"next": "Pagina successiva",
"items-per-page": "Elementi per pagina",
"page": "Pagina {{page}}",
"gotofirst": "Vai alla prima pagina",
"gotopage": "Vai a pagina {{page}}",
"gotolast": "Vai all'ultima pagina"
},
"debug": {
"dev-build": "Build di sviluppo"
},
"menu": {
"messages": {
"update-available": "Aggiornamento disponibile"
},
"pages": {
"loyalty": {
"configuration": "Configurazione",
"points": "Punti e ricompense",
"rewards": "Ricompense e obiettivi"
},
"monitor": {
"dashboard": "Vista generale"
},
"strimertul": {
"settings": "Impostazioni server",
"ui-config": "Opzioni interfaccia",
"extensions": "Estensioni"
},
"twitch": {
"chat-alerts": "Avvisi in chat",
"chat-commands": "Comandi chat",
"chat-timers": "Timer chat",
"configuration": "Configurazione"
}
},
"sections": {
"loyalty": "Punti fedeltà",
"monitor": "Monitor",
"strimertul": "strimertul",
"twitch": "Twitch",
"monitor-short": "PANL",
"strimertul-short": "STUL",
"twitch-short": "TWCH",
"loyalty-short": "PNTI"
}
},
"pages": {
"botcommands": {
"remove-command-title": "Rimuovere il comando {{name}}?",
"command-name": "Nome comando",
"command-name-placeholder": "!comando",
"command-desc": "Descrizione (opzionale)",
"command-desc-placeholder": "Questo comando fa qualcosa",
"command-response": "Risposta",
"command-response-placeholder": "Ciao {0}!",
"command-acl": "Livello d'accesso richiesto",
"command-acl-help": "Specifica il livello minimo richiesto, ad esempio se scegli VIP, sia VIP che moderatori che lo streamer potranno usare il comando",
"title": "Comandi del bot",
"desc": "Crea comandi chat personalizzati per autorisponditori, contatori, ecc.",
"add-button": "Crea comando",
"search-placeholder": "Cerca comando per nome",
"no-commands": "Il bot non ha comandi configurati",
"command-header-new": "Nuovo comando",
"command-header-edit": "Modifica comando",
"acl": {
"everyone": "Chiunque",
"moderators": "Moderatori",
"streamer": "Solo streamer",
"subscribers": "Abbonati",
"vip": "VIP"
},
"command-already-in-use": "Nome comando già in uso",
"response-types": {
"announce": "Annuncio",
"chat": "Canale",
"reply": "Risposta",
"whisper": "Chat privata"
},
"command-invalid-format": "Il messaggio contiene errori"
},
"bottimers": {
"add-button": "Nuovo timer",
"desc": "Definisci promemoria tipo seguire i tuoi social o eventi in corso",
"no-timers": "Non ci sono timer configurati",
"remove-timer-title": "Rimuovere il timer {{timer}}?",
"search-placeholder": "Cerca timer per nome",
"timer-activity": "Attività in chat minima (0 per disabilitare)",
"timer-activity-desc": "messaggi negli ultimi 5 minuti",
"timer-header-edit": "Modifica timer",
"timer-header-new": "Nuovo timer",
"timer-interval": "Intervallo minimo",
"timer-messages": "Messaggi",
"timer-name": "Nome timer",
"timer-name-placeholder": "mio-timer",
"timer-parameters": "ogni {{time}}, ≥ {{messages}} messaggi negli ultimi {{interval}}",
"title": "Timer del bot",
"name-already-in-use": "Nome timer già in uso"
},
"dashboard": {
"live": "In onda!",
"not-live": "Offline / Non in streaming",
"twitch-status": "Stato stream Twitch",
"x-viewers": "{{num}} spettatori",
"twitch-events": {
"anonymous": "Uno spettatore anonimo",
"header": "Eventi recenti",
"warning": "Questa sezione contiene solo gli eventi accaduti mentre {{APPNAME}} era aperto, quindi utilizzala solo per cose recenti",
"marker": "Eventi delle sessioni precedenti",
"events": {
"channel-updated": "Informazioni stream modificate",
"cheered": "<n>{{name}}</n> ti ha tifato con <b>{{bits}} bit</b>",
"follow": "<n>{{name}}</n> ti ha seguito",
"raided": "<n>{{name}}</n> ti ha fatto un raid con <v>{{viewers}} spettatori</v>",
"redemption": "<n>{{name}}</n> ha riscattato <r>{{reward}}</r>",
"stream-start": "Hai iniziato un nuovo stream",
"stream-stop": "Hai chiuso lo stream",
"subscribed": "<n>{{name}}</n> si è abbonato <t>(Livello {{tier}})</t>",
"subscribed-multi": "<n>{{name}}</n> si è abbonato <m>({{months}} mesi)</m> <t>(Livello {{tier}})</t>",
"subscrition-gift_one": "<n>{{name}}</n> ha regalato <c>{{count}}</c> abbonamento <t>(Livello {{tier}})</t>",
"subscrition-gift_other": "<n>{{name}}</n> ha regalato <c>{{count}}</c> abbonamenti <t>(Livello {{tier}})</t>"
},
"replay": "Ripeti evento"
},
"link-api": "Documentazione API",
"link-user-guide": "Guida utente",
"quick-links": "Link utili",
"problems": {
"eventsub-scope": "{{APPNAME}} necessita di nuove autorizzazioni nella tua app Twitch per funzionare correttamente.<br/> Fai clic <a>qui</a> per autenticarti nuovamente."
}
},
"debug": {
"big-ass-warning": "L'utilizzo di questa pagina può danneggiare gravemente il tuo database. \nSpero tu sappia cosa stai facendo!",
"console-ops": "Operazioni su console",
"disclaimer-header": "Scritta gigante spaventosa",
"dismiss-warning": "Non ho paura! \n...beh ok forse un po' sì",
"dump-all": "Stampa tutti i valori KV come JSON",
"dump-keys": "Stampa tutte le chiavi a DB",
"fix-json": "Correggi JSON",
"read-key": "Leggi chiave DB",
"title": "Operazioni di debug",
"write-key": "Scrivi chiave DB"
},
"alerts": {
"cheer-enable": "Abilita messaggio per bit",
"desc": "Invia messaggi in chat quando i tuoi spettatori seguono, si abbonano o altri eventi",
"follow-enable": "Abilita messaggio per nuovo follower",
"gift_sub-enable": "Abilita messaggio per regalo abbonamento",
"messages": "Messaggi",
"msg-info": "Se sono presenti più messaggi, ne verrà scelto uno a caso",
"raid-enable": "Abilita messaggio per raid",
"subscription-enable": "Abilita messaggio per abbonamenti",
"title": "Avvisi in chat",
"events": {
"cheer": "Bit",
"follow": "Nuovo follower",
"gift-sub": "Regalo abbonamento",
"raid": "Raid",
"subscription": "Abbonamento"
}
},
"http": {
"bind": "Indirizzo/porta server",
"bind-help": "Ogni applicazione che utilizza {{APPNAME}} dovrà essere aggiornata!",
"bind-placeholder": "indirizzo:porta",
"kilovolt-password": "Password kilovolt",
"kilovolt-placeholder": "Lascia vuoto per disabilitare l'autenticazione (non consigliato)",
"saving": "Salvataggio delle impostazioni del server...",
"static-help": "Sarà disponibile al seguente URL: {{url}}",
"static-path": "Risorse statiche (lasciare vuoto per disabilitare)",
"static-placeholder": "Percorso completo alle risorse statiche",
"title": "Impostazioni del server",
"kv-auth-warning": {
"go-back": "Torna indietro",
"header": "Sei sicuro di quello che stai facendo?",
"i-understand": "Accetto il rischio",
"message": "Hai lasciato vuoto il campo della password Kilovolt! \nQuesto lascerà {{APPNAME}} accessibile senza autenticazione da qualsiasi applicazione, incluso qualsiasi sito web che visiti!"
}
},
"loyalty-queue": {
"accept": "Accetta",
"date": "Data",
"give-points-dialog": "Dai punti",
"hardcoded": "Testo a caso qui",
"modify-balance-dialog": "Modifica bilancio",
"no-redeems": "Nessun riscatto in sospeso",
"no-users": "Nessun spettatore trovato",
"points": "Punti",
"queue-tab": "Coda riscatti",
"refund": "Rimborsa",
"request": "Richiesta",
"reward": "Ricompensa",
"title": "Punti e ricompense",
"subtitle": "Classifica punti e ricompense in attesa",
"username": "Spettatore",
"username-filter": "Cerca per nome utente",
"users-tab": "Gestisci punti"
},
"loyalty-rewards": {
"create-goal": "Crea obiettivo",
"create-reward": "Crea ricompensa",
"edit-goal": "Modifica obiettivo",
"edit-reward": "Modifica ricompensa",
"goal-cost": "Punti totali richiesti",
"goal-desc": "Descrizione",
"goal-filter": "Cerca per nome obiettivo",
"goal-icon": "Icona obiettivo (URL)",
"goal-id": "ID obiettivo",
"goal-id-hint": "Questo è ciò che gli spettatori dovranno scrivere per contribuire, ad es. \n\"!contribuisci id-obiettivo-qui\".",
"goal-name": "Nome",
"goal-name-hint": "Questo è ciò che gli spettatori vedranno quando contribuiranno, ad es. \n\"SPETTATORE ha contribuito a NOMEOBIETTIVO\"",
"goals-tab": "Obiettivi",
"id-already-in-use": "ID già in uso",
"no-goals": "Non ci sono obiettivi configurati",
"no-rewards": "Non ci sono ricompense, perché non iniziare con un \"Bevi\" o \"Fai stretching\"?",
"remove-reward-title": "Rimuovere ricompensa \"{{name}}\"?",
"reward-cooldown": "Tempo di ricarica",
"reward-cost": "Prezzo",
"reward-desc": "Descrizione",
"reward-details": "Richiedi dettagli",
"reward-details-placeholder": "Quali dettagli extra chiedere allo spettatore",
"reward-filter": "Cerca per nome ricompensa",
"reward-icon": "Icona (URL)",
"reward-id": "ID ricompensa",
"reward-id-hint": "Questo è ciò che gli spettatori dovranno scrivere per riscattare, ad es. \n\"!redeem id-premio-qui\".",
"reward-name": "Nome",
"reward-name-hint": "Questo è ciò che gli spettatori vedranno quando riscatteranno, ad es. \n\"SPETTATORE ha riscattato NOMERICOMPENSA\"",
"rewards-tab": "Ricompense",
"subtitle": "Imposta ricompense ed obiettivi della community con cui i tuoi spettatori possono interagire",
"title": "Ricompense e obiettivi"
},
"loyalty-settings": {
"bonus-points": "Punti bonus per i spettatori attivi",
"bonus-points-hint": "Quantità extra di punti assegnati alle persone che hanno chattato nell'ultimo intervallo impostato",
"currency-name": "Nome dei punti",
"currency-name-hint": "Questo verrà usato in questo modo: \"persona ha X nomequi\" quindi scegli un nome plurale minuscolo (es. punti)",
"currency-placeholder": "punti",
"enable": "Abilita punti fedeltà",
"every": "ogni",
"note": "Nota: a differenza di come funziona nelle piattaforme (ad es. Punti canale Twitch), questo si basa sull'attività in chat piuttosto che sullo stato effettivo di visualizzazione.",
"reward": "Quanto spesso dare {{currency}}",
"subtitle": "Punti fedeltà che consentono agli spettatori di accumulare punti e spenderli in ricompense e obiettivi",
"title": "Configurazione punti fedeltà"
},
"onboarding": {
"skip-button": "Salta procedura guidata",
"welcome-continue-button": "Cominciamo",
"welcome-header": "Benvenuto su {{APPNAME}}",
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento.",
"welcome-p2": "Giusto una cosa: se sei abituato ad altri strumenti di questo tipo, stavolta toccherà un po' più lavoro da parte tua!",
"sections": {
"done": "Pronti a partire!",
"landing": "Benvenuto",
"twitch-bot": "Bot per Twitch",
"twitch-config": "Integrazione Twitch",
"twitch-events": "Eventi Twitch"
},
"done-button": "Completa procedura guidata",
"done-p1": "Dovremmo esserci per ora. \nPuoi sempre modificare qualsiasi opzione in un secondo momento, comprese configurazioni personalizzate non trattate in questa procedura (ad esempio utilizzare un account Twitch diverso per il bot).",
"done-p3": "Fai clic sul pulsante qui sotto per completare questa procedura ed andare nella dashboard di {{APPNAME}}.",
"twitch-complete": "Completa integrazione Twitch",
"twitch-ev-p3": "Se vedi qui sopra il nome e l'immagine profilo del tuo account, sei a posto! \nFai clic sul pulsante in basso per completare l'integrazione con Twitch.",
"twitch-p2": "Fai clic su \"Test connessione\" per assicurarti che l'ID client e il segreto specificati siano validi, se il test ha esito positivo verrai portato automaticamente al passaggio successivo.",
"twitch-skip": "Salta integrazione con Twitch",
"twitch-p1": "Per configurare Twitch dovrai creare un'applicazione sul portale per gli sviluppatori. Segui le istruzioni di seguito o fai clic sul pulsante in basso per saltare questo passaggio.",
"twitch-ev-p1": "Ora che hai creato un'app, devi autenticarci il tuo account Twitch in modo che possiamo accedere a dati come il nome del tuo canale o eventi come nuovi follower o raid.",
"done-p2": "In caso di domande o problemi, contattaci in uno di questi modi:",
"done-header": "È tutto pronto!",
"welcome-guide": "Sarebbe una buona idea tenere aperta la guida utente di {{APPNAME}} nel caso incontrassi difficoltà con uno dei seguenti passaggi, puoi aprirla <g>cliccando qui</g>."
},
"strimertul": {
"credits-header": "Ringraziamenti",
"credits-renko": "Renko, mascotte e icona di {{APPNAME}}, è stata disegnata da <artist>Sonic_Chan</artist>",
"license-header": "Licenza",
"license-notice-strimertul": "{{APPNAME}} è concesso sotto licenza <license>GNU Affero General Public License v3.0</license>",
"need-help": "Hai bisogno di aiuto?",
"need-help-p1": "Se hai bisogno di aiuto, vuoi segnalare un bug o hai suggerimenti su come migliorare {{APPNAME}}, contattaci tramite uno dei seguenti canali:"
},
"twitch-settings": {
"api-configuration": "Accesso API",
"api-subheader": "Info applicazione",
"apiguide-1": "Dovrai creare un'applicazione, ecco come:",
"apiguide-2": "Vai su <1>https://dev.twitch.tv/console/apps/create</1>",
"apiguide-3": "Utilizza i seguenti dati per i campi obbligatori:",
"apiguide-4": "Una volta creato, crea un <1>Nuovo segreto</1>, quindi copia entrambi i campi qui sotto e salva!",
"app-client-id": "ID client",
"app-category": "Categoria",
"app-client-secret": "Segreto client",
"app-oauth-redirect-url": "Reindirizzamento URL OAuth",
"chat-settings": "Impostazioni chat",
"enable": "Abilita integrazione Twitch",
"eventsub": "Eventi",
"subtitle": "Integrazione con stream su Twitch, incluso chat bot e accesso API. \nSe usi Twitch come piattaforma di streaming, lo vorrai sicuramente.",
"title": "Configurazione Twitch",
"chat": {
"cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)",
"chat-account": "Account chat",
"header": "Impostazioni chat",
"default-user": "Utilizzando l'account principale, usa il pulsante qui sopra per autenticarti con un account diverso per le funzionalità di chat.",
"clear-button": "Torna ad usare l'account principale",
"account-copy": "Puoi utilizzare un account diverso per rispondendere ai comandi della chat e invia notifiche al posto di quello del tuo canale. \nPer fare ciò, fai clic sul pulsante in basso e autentica e autorizza l'utilizzo del tuo account secondario."
},
"events": {
"auth-button": "Autenticati via Twitch",
"auth-message": "Fai clic sul pulsante qui sotto per autorizzare {{APPNAME}} ad accedere a notifiche del tuo account Twitch:",
"authenticated-as": "Autenticato come",
"current-status": "Stato attuale",
"err-no-user": "Nessun utente twitch è attualmente associato",
"loading-data": "Sto chiedendo i dati utente a Twitch...",
"profile-picture": "Immagine del profilo",
"sim-events": "Invia evento di prova",
"sim": {
"channel.update": "Aggiornamento canale",
"channel.follow": "Nuovo follower",
"channel.subscribe": "Nuovo abbonato",
"channel.subscription.gift": "Regalo abbonamento",
"channel.subscription.message": "Abbonamento con messaggio",
"channel.cheer": "Tifo",
"channel.raid": "Raid"
}
},
"test-button": "Test connessione",
"test-failed": "Test fallito: \"{{error}}\". \nControlla ID e segreto client dell'app!",
"test-succeeded": "Test riuscito!"
},
"uiconfig": {
"language": "Lingua",
"partial-translation": "Traduzione parziale",
"repeat-onboarding": "Ripeti procudura di configurazione",
"title": "Impostazioni interfaccia utente",
"theme": "Tema",
"themes": {
"dark": "Scuro",
"light": "Chiaro"
}
},
"extensions": {
"create": "Crea nuovo",
"loading": "Solo un secondo, il sistema di estensioni si sta ancora preparando!",
"remove-alert": "Rimuovere l'estensione \"{{name}}\"?",
"format": "Formatta codice",
"name-already-in-use": "Nome già in uso",
"rename": "Rinomina estensione",
"rename-dialog": "Rinominare l'estensione \"{{name}}\"",
"rename-new-name": "Nuovo nome",
"search": "Cerca per nome",
"statuses": {
"error": "Errore riscontrato",
"main-loop-finished": "Attivo",
"not-ready": "Caricamento in corso",
"ready": "Non attivo",
"running": "In esecuzione",
"terminated": "Fermato"
},
"tab-editor": "Editor",
"tab-manage": "Gestisci",
"title": "Estensioni",
"error-alert": "Dettagli errore per {{name}}",
"incompatible-body": "Questa estensione richiede {{APPNAME}} versione {{version}} o successive, al momento stai utilizzando {{appversion}} che potrebbe essere troppo vecchia e perciò senza alcune funzionalità richieste",
"incompatible-warning": "Questa estensione non è compatibile"
},
"crash": {
"action-header": "Che fare?",
"action-recover-line": "Se questo errore si verifica ogni volta che avvii l'app o se ti è stato richiesto di farlo, fai clic sul pulsante Ripristino per recuperare il database da un backup precedente.",
"action-log-line": "I log e altre informazioni relative agli arresti anomali sono disponibili nei file di log <m>{{A}}</m> e <m>{{B}}</m>.",
"action-submit-line": "Puoi inviare una segnalazione di questo arresto anomalo utilizzando il pulsante Segnala in basso in modo che qualcuno possa esaminarla.",
"app-log-header": "Log applicazione per questa esecuzione",
"button-recovery": "Menù ripristino",
"button-report": "Segnala questo errore",
"fatal-message": "Si è verificato un errore irreversibile e strimertül ha smesso di funzionare, ecco i dettagli:",
"report": {
"additional-label": "Info aggiuntive (opzionale)",
"button-send": "Invia segnalazione",
"dialog-title": "Segnala questo errore",
"text-placeholder": "Cosa stavi facendo nell'applicazione?",
"thanks-line": "Grazie per aver scelto di segnalare questo errore! \nSe vuoi, scrivi qui sotto cosa stavi cercando di fare o qualsiasi altra cosa che pensi possa essere d'aiuto.",
"email-label": "Includi una email (se vuoi essere contattato in merito)",
"email-placeholder": "Scrivi qui il tuo indirizzo email",
"transparency-files": "I contenuti di <m>{{A}}</m> e <m>{{B}}</m>",
"transparency-info": "Informazioni sull'errore specifico che ha causato il crash",
"transparency-line": "Facendo clic su \"Invia segnalazione\", verranno raccolte e inviate i seguenti dati:",
"transparency-user": "Le informazioni aggiuntive di seguito, se fornite",
"error-message": "Non è stato possibile inviare la segnalazione di errore a causa di un errore remoto: {{error}}",
"post-report": "L'errore è stato segnalato con successo ed è stato assegnato il seguente codice: <m>{{code}}</m> Se non hai fornito un'e-mail e vuoi contattarci in merito, usa quel codice quando apri una segnalazione o altro contatto."
},
"recovery": {
"restore-button": "Ripristina",
"restore-confirm-body": "Il ripristino di questo backup sovrascriverà il database attuale, questa operazione è irreversibile.",
"restore-confirm-title": "Conferma ripristino del database",
"restore-desc-1": "Ripristina il database utilizzando un backup creato precedentemente. \nQuesto sovrascriverà il database attuale con la copia salvata. \nQui di seguito è la lista di tutti i backup attualmente salvati.",
"restore-error": "Impossibile ripristinare il database a causa del seguente errore: {{error}}",
"restore-failed": "Ripristino non riuscito",
"restore-head": "Ripristina dal backup",
"text-head": "Queste azioni modificheranno irreversibilmente il database, assicurati che il database sia effettivamente danneggiato prima di procedere.",
"title": "Opzioni di ripristino",
"restore-succeeded-title": "Database ripristinato",
"restore-succeeded-body": "Il database è stato ripristinato dal backup scelto, chiudi e riapri {{APPNAME}}."
}
},
"interactive-auth": {
"allow": "Consenti",
"deny": "Nega",
"title": "Un'applicazione sta tentando di accedere a {{APPNAME}}",
"unknown-name": "Applicazione sconosciuta",
"verification-code": "Come ulteriore misura di sicurezza, verifica anche che l'applicazione mostri questo codice:",
"warn-1": "Consenti solo se conosci e ti fidi dell'applicazione.",
"desc-1": "Un'applicazione vuole accedere a {{APPNAME}}. \nConsentire ciò renderà l'applicazione in grado di interagire e controllare {{APPNAME}}. \nCiò include l'accesso a dati sensibili contenuti nel database.",
"info-present": "L'applicazione si identifica come di seguito:"
}
},
"time": {
"hours": "ore",
"minutes": "minuti",
"seconds": "secondi",
"x-hours": "{{time}} ore",
"x-minutes": "{{tempo}} min",
"x-seconds": "{{time}} sec"
}
}

View File

@ -1,34 +1,31 @@
import { ResourceKey } from 'i18next';
import en from './en/translation.json';
import it from './it/translation.json';
import type { ResourceKey } from "i18next";
import en from "./en/translation.json";
import it from "./it/translation.json";
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,
);
if (typeof res === "string") {
return 1;
}
return Object.values(res).reduce<number>((acc: number, k: ResourceKey) => acc + countKeys(k), 0);
}
interface LanguageMeta {
'language-name': string;
"language-name": string;
}
export const resources = {
en: {
translation: en,
},
it: {
translation: it,
},
en: {
translation: en,
},
it: {
translation: it,
},
} as const;
export const languages = Object.entries(resources).map(([code, lang]) => ({
code,
name: (lang.translation.$meta as LanguageMeta)['language-name'] || code,
keys: countKeys(lang),
code,
name: (lang.translation.$meta as LanguageMeta)["language-name"] || code,
keys: countKeys(lang),
}));
export default resources;

View File

@ -1,16 +1,16 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { APPNAME } from '~/ui/theme';
import { resources } from './languages';
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { APPNAME } from "~/ui/theme";
import { resources } from "./languages";
void i18n.use(initReactI18next).init({
resources,
lng: navigator.language,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
defaultVariables: {
APPNAME,
},
},
resources,
lng: navigator.language,
fallbackLng: "en",
interpolation: {
escapeValue: false,
defaultVariables: {
APPNAME,
},
},
});

View File

@ -1,427 +1,399 @@
/* eslint-disable no-param-reassign */
import {
AsyncThunk,
CaseReducer,
createAction,
createAsyncThunk,
createSlice,
Dispatch,
PayloadAction,
UnknownAction,
} from '@reduxjs/toolkit';
import KilovoltWS, { 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';
type AsyncThunk,
type CaseReducer,
createAction,
createAsyncThunk,
createSlice,
type Dispatch,
type PayloadAction,
type UnknownAction,
} from "@reduxjs/toolkit";
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,
ConnectionStatus,
HTTPConfig,
LoyaltyPointsEntry,
LoyaltyRedeem,
LoyaltyStorage,
TwitchChatConfig,
TwitchConfig,
TwitchChatCustomCommands,
TwitchChatTimersConfig,
TwitchChatAlertsConfig,
LoyaltyConfig,
LoyaltyReward,
LoyaltyGoal,
UISettings,
} from './types';
import { ThunkConfig } from '..';
type APIState,
ConnectionStatus,
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 type { ThunkConfig } from "..";
type ThunkAPIState = { api: APIState };
interface AppThunkAPI {
dispatch: Dispatch;
getState: () => ThunkAPIState;
dispatch: Dispatch;
getState: () => ThunkAPIState;
}
function makeGetSetThunks<T>(key: string) {
const getter = createAsyncThunk<T, void, { state: ThunkAPIState }>(
`api/get/${key}`,
async (_, { getState }) => {
const { api } = getState();
return api.client.getJSON<T>(key);
},
);
const setter = createAsyncThunk<KilovoltMessage, T, { state: ThunkAPIState }>(
`api/set/${key}`,
async (data: T, { getState, dispatch }: AppThunkAPI) => {
const { api } = getState();
const result = await api.client.putJSON(key, data);
if ('ok' in result) {
if (result.ok) {
// Re-load value from KV
// Need to do type fuckery to avoid cyclic redundancy
// (unless there's a better way that I'm missing)
void dispatch(getter() as unknown as UnknownAction);
}
}
return result;
},
);
return { getter, setter };
const getter = createAsyncThunk<T, void, { state: ThunkAPIState }>(
`api/get/${key}`,
async (_, { getState }) => {
const { api } = getState();
return api.client.getJSON<T>(key);
},
);
const setter = createAsyncThunk<KilovoltMessage, T, { state: ThunkAPIState }>(
`api/set/${key}`,
async (data: T, { getState, dispatch }: AppThunkAPI) => {
const { api } = getState();
const result = await api.client.putJSON(key, data);
if ("ok" in result) {
if (result.ok) {
// Re-load value from KV
// Need to do type fuckery to avoid cyclic redundancy
// (unless there's a better way that I'm missing)
void dispatch(getter() as unknown as UnknownAction);
}
}
return result;
},
);
return { getter, setter };
}
function makeModule<T>(
key: string,
selector: (state: APIState) => T,
stateSetter: CaseReducer<APIState>,
key: string,
selector: (state: APIState) => T,
stateSetter: CaseReducer<APIState>,
) {
return {
...makeGetSetThunks<T>(key),
key,
selector,
stateSetter,
asyncSetter: createAction<T>(`asyncSetter/${key}`),
};
return {
...makeGetSetThunks<T>(key),
key,
selector,
stateSetter,
asyncSetter: createAction<T>(`asyncSetter/${key}`),
};
}
// 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/';
const loyaltyRewardsKey = 'loyalty/rewards';
const loyaltyPointsPrefix = "loyalty/points/";
const loyaltyRewardsKey = "loyalty/rewards";
// RPCs
const loyaltyCreateRedeemKey = 'loyalty/@create-redeem';
const loyaltyRemoveRedeemKey = 'loyalty/@remove-redeem';
const loyaltyCreateRedeemKey = "loyalty/@create-redeem";
const loyaltyRemoveRedeemKey = "loyalty/@remove-redeem";
export const createWSClient = createAsyncThunk(
'api/createClient',
async (options: { address: string; password?: string }, { dispatch }) => {
// Wait for server to be ready
let ready = false;
while (!ready) {
// eslint-disable-next-line no-await-in-loop
ready = await IsServerReady();
if (ready) {
break;
}
// eslint-disable-next-line no-await-in-loop
await delay(1000);
}
// Connect to websocket
const client = new KilovoltWS(options.address, {
password: options.password,
});
client.on('error', (err: CustomEvent<kvError>) => {
void dispatch(kvErrorReceived(err.detail));
});
await client.connect();
await dispatch(setupClientReconnect(client));
return client;
},
"api/createClient",
async (options: { address: string; password?: string }, { dispatch }) => {
// Wait for server to be ready
let ready = false;
while (!ready) {
// eslint-disable-next-line no-await-in-loop
ready = await IsServerReady();
if (ready) {
break;
}
// eslint-disable-next-line no-await-in-loop
await delay(1000);
}
// Connect to websocket
const client = new KilovoltWS(options.address, {
password: options.password,
});
client.on("error", (err: CustomEvent<kvError>) => {
void dispatch(kvErrorReceived(err.detail));
});
await client.connect();
await dispatch(setupClientReconnect(client));
return client;
},
);
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,
{ user: string; points: number; relative: boolean },
ThunkConfig
>('api/setUserPoints', async ({ user, points, relative }, { getState }) => {
const { api } = getState();
const entry: LoyaltyPointsEntry = { points };
if (relative) {
entry.points += api.loyalty.users[user]?.points ?? 0;
}
return api.client.putJSON(loyaltyPointsPrefix + user, entry);
KilovoltMessage,
{ user: string; points: number; relative: boolean },
ThunkConfig
>("api/setUserPoints", async ({ user, points, relative }, { getState }) => {
const { api } = getState();
const entry: LoyaltyPointsEntry = { points };
if (relative) {
entry.points += api.loyalty.users[user]?.points ?? 0;
}
return api.client.putJSON(loyaltyPointsPrefix + user, entry);
});
export const modules = {
httpConfig: makeModule(
'http/config',
(state) => state.moduleConfigs?.httpConfig,
(state, { payload }) => {
state.moduleConfigs.httpConfig = payload as HTTPConfig;
},
),
twitchConfig: makeModule(
'twitch/config',
(state) => state.moduleConfigs?.twitchConfig,
(state, { payload }) => {
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
},
),
twitchChatConfig: makeModule(
'twitch/chat/config',
(state) => state.moduleConfigs?.twitchChatConfig,
(state, { payload }) => {
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
},
),
twitchChatCommands: makeModule(
'twitch/chat/custom-commands',
(state) => state.twitchChat?.commands,
(state, { payload }) => {
state.twitchChat.commands = payload as TwitchChatCustomCommands;
},
),
twitchChatTimers: makeModule(
'twitch/timers/config',
(state) => state.twitchChat?.timers,
(state, { payload }) => {
state.twitchChat.timers = payload as TwitchChatTimersConfig;
},
),
twitchChatAlerts: makeModule(
'twitch/alerts/config',
(state) => state.twitchChat?.alerts,
(state, { payload }) => {
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
},
),
loyaltyConfig: makeModule(
'loyalty/config',
(state) => state.moduleConfigs?.loyaltyConfig,
(state, { payload }) => {
state.moduleConfigs.loyaltyConfig = payload as LoyaltyConfig;
},
),
loyaltyRewards: makeModule(
loyaltyRewardsKey,
(state) => state.loyalty.rewards,
(state, { payload }) => {
state.loyalty.rewards = payload as LoyaltyReward[];
},
),
loyaltyGoals: makeModule(
'loyalty/goals',
(state) => state.loyalty.goals,
(state, { payload }) => {
state.loyalty.goals = payload as LoyaltyGoal[];
},
),
loyaltyRedeemQueue: makeModule(
'loyalty/redeem-queue',
(state) => state.loyalty.redeemQueue,
(state, { payload }) => {
state.loyalty.redeemQueue = payload as LoyaltyRedeem[];
},
),
uiConfig: makeModule(
'ui/settings',
(state) => state.uiConfig,
(state, { payload }) => {
state.uiConfig = payload as UISettings;
},
),
httpConfig: makeModule(
"http/config",
(state) => state.moduleConfigs?.httpConfig,
(state, { payload }) => {
state.moduleConfigs.httpConfig = payload as HTTPConfig;
},
),
twitchConfig: makeModule(
"twitch/config",
(state) => state.moduleConfigs?.twitchConfig,
(state, { payload }) => {
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
},
),
twitchChatConfig: makeModule(
"twitch/chat/config",
(state) => state.moduleConfigs?.twitchChatConfig,
(state, { payload }) => {
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
},
),
twitchChatCommands: makeModule(
"twitch/chat/custom-commands",
(state) => state.twitchChat?.commands,
(state, { payload }) => {
state.twitchChat.commands = payload as TwitchChatCustomCommands;
},
),
twitchChatTimers: makeModule(
"twitch/timers/config",
(state) => state.twitchChat?.timers,
(state, { payload }) => {
state.twitchChat.timers = payload as TwitchChatTimersConfig;
},
),
twitchChatAlerts: makeModule(
"twitch/alerts/config",
(state) => state.twitchChat?.alerts,
(state, { payload }) => {
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
},
),
loyaltyConfig: makeModule(
"loyalty/config",
(state) => state.moduleConfigs?.loyaltyConfig,
(state, { payload }) => {
state.moduleConfigs.loyaltyConfig = payload as LoyaltyConfig;
},
),
loyaltyRewards: makeModule(
loyaltyRewardsKey,
(state) => state.loyalty.rewards,
(state, { payload }) => {
state.loyalty.rewards = payload as LoyaltyReward[];
},
),
loyaltyGoals: makeModule(
"loyalty/goals",
(state) => state.loyalty.goals,
(state, { payload }) => {
state.loyalty.goals = payload as LoyaltyGoal[];
},
),
loyaltyRedeemQueue: makeModule(
"loyalty/redeem-queue",
(state) => state.loyalty.redeemQueue,
(state, { payload }) => {
state.loyalty.redeemQueue = payload as LoyaltyRedeem[];
},
),
uiConfig: makeModule(
"ui/settings",
(state) => state.uiConfig,
(state, { payload }) => {
state.uiConfig = payload as UISettings;
},
),
};
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
`${keyof typeof modules}Changed`,
(state: APIState, action: PayloadAction<unknown>) => never
>;
const initialState: APIState = {
client: null,
connectionStatus: ConnectionStatus.NotConnected,
kvError: null,
initialLoadComplete: false,
loyalty: {
users: null,
rewards: null,
goals: null,
redeemQueue: null,
},
twitchChat: {
commands: null,
timers: null,
alerts: null,
},
moduleConfigs: {
httpConfig: null,
twitchConfig: null,
twitchChatConfig: null,
loyaltyConfig: null,
},
uiConfig: null,
requestStatus: {},
client: null,
connectionStatus: ConnectionStatus.NotConnected,
kvError: null,
initialLoadComplete: false,
loyalty: {
users: null,
rewards: null,
goals: null,
redeemQueue: null,
},
twitchChat: {
commands: null,
timers: null,
alerts: null,
},
moduleConfigs: {
httpConfig: null,
twitchConfig: null,
twitchChatConfig: null,
loyaltyConfig: null,
},
uiConfig: null,
requestStatus: {},
};
const apiReducer = createSlice({
name: 'api',
initialState,
reducers: {
...moduleChangeReducers,
initialLoadCompleted(state) {
state.initialLoadComplete = true;
},
connectionStatusChanged(
state,
{ payload }: PayloadAction<ConnectionStatus>,
) {
state.connectionStatus = payload;
},
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
state.kvError = payload;
},
loyaltyUserPointsChanged(
state,
{
payload: { user, entry },
}: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
) {
state.loyalty.users[user] = entry;
},
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
payload.forEach((key) => {
delete state.requestStatus[key];
});
},
},
extraReducers: (builder) => {
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
state.client = payload;
state.connectionStatus = ConnectionStatus.Connected;
});
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
state.loyalty.users = payload;
});
Object.values(modules).forEach((mod) => {
builder.addCase(mod.getter.pending, (state) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.getter.fulfilled, (state, action) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
mod.stateSetter(state, action);
});
builder.addCase(mod.getter.rejected, (state, { error }) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.setter.pending, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.setter.fulfilled, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
});
builder.addCase(mod.setter.rejected, (state, { error }) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.asyncSetter, mod.stateSetter);
});
},
name: "api",
initialState,
reducers: {
...moduleChangeReducers,
initialLoadCompleted(state) {
state.initialLoadComplete = true;
},
connectionStatusChanged(state, { payload }: PayloadAction<ConnectionStatus>) {
state.connectionStatus = payload;
},
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
state.kvError = payload;
},
loyaltyUserPointsChanged(
state,
{ payload: { user, entry } }: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
) {
state.loyalty.users[user] = entry;
},
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
for (const key of payload) {
delete state.requestStatus[key];
}
},
},
extraReducers: (builder) => {
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
state.client = payload;
state.connectionStatus = ConnectionStatus.Connected;
});
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
state.loyalty.users = payload;
});
for (const mod of Object.values(modules)) {
builder.addCase(mod.getter.pending, (state) => {
state.requestStatus[`load-${mod.key}`] = {
type: "pending",
updated: new Date(),
};
});
builder.addCase(mod.getter.fulfilled, (state, action) => {
state.requestStatus[`load-${mod.key}`] = {
type: "success",
updated: new Date(),
};
mod.stateSetter(state, action);
});
builder.addCase(mod.getter.rejected, (state, { error }) => {
state.requestStatus[`load-${mod.key}`] = {
type: "error",
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.setter.pending, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: "pending",
updated: new Date(),
};
});
builder.addCase(mod.setter.fulfilled, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: "success",
updated: new Date(),
};
});
builder.addCase(mod.setter.rejected, (state, { error }) => {
state.requestStatus[`save-${mod.key}`] = {
type: "error",
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.asyncSetter, mod.stateSetter);
}
},
});
setupClientReconnect = createAsyncThunk(
'api/setupClientReconnect',
(client: KilovoltWS, { dispatch }) => {
client.on('close', () => {
setTimeout(() => {
console.info('Attempting reconnection');
client.reconnect();
}, 5000);
dispatch(
apiReducer.actions.connectionStatusChanged(
ConnectionStatus.NotConnected,
),
);
});
client.on('open', () => {
dispatch(
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
);
});
},
"api/setupClientReconnect",
(client: KilovoltWS, { dispatch }) => {
client.on("close", () => {
setTimeout(() => {
console.info("Attempting reconnection");
client.reconnect();
}, 5000);
dispatch(apiReducer.actions.connectionStatusChanged(ConnectionStatus.NotConnected));
});
client.on("open", () => {
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 }) => {
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),
);
}
},
"api/authBypass",
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));
}
},
);
export default apiReducer;

View File

@ -1,187 +1,181 @@
/* eslint-disable camelcase */
import KilovoltWS from '@strimertul/kilovolt-client';
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
import type KilovoltWS from "@strimertul/kilovolt-client";
import type { kvError } from "@strimertul/kilovolt-client/types/messages";
export interface HTTPConfig {
bind: string;
enable_static_server: boolean;
kv_password: string;
path: string;
bind: string;
enable_static_server: boolean;
kv_password: string;
path: string;
}
export interface TwitchConfig {
enabled: boolean;
api_client_id: string;
api_client_secret: string;
enabled: boolean;
api_client_id: string;
api_client_secret: string;
}
export interface TwitchChatConfig {
command_cooldown: number;
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];
export type ReplyType = 'chat' | 'reply' | 'whisper' | 'announce';
export type ReplyType = "chat" | "reply" | "whisper" | "announce";
export interface TwitchChatCustomCommand {
description: string;
access_level: AccessLevelType;
response: string;
response_type: ReplyType;
enabled: boolean;
description: string;
access_level: AccessLevelType;
response: string;
response_type: ReplyType;
enabled: boolean;
}
export type TwitchChatCustomCommands = Record<string, TwitchChatCustomCommand>;
export interface LoyaltyConfig {
enabled: boolean;
currency: string;
points: {
interval: number;
amount: number;
activity_bonus: number;
};
banlist: string[];
enabled: boolean;
currency: string;
points: {
interval: number;
amount: number;
activity_bonus: number;
};
banlist: string[];
}
export interface TwitchChatTimer {
enabled: boolean;
name: string;
minimum_chat_activity: number;
minimum_delay: number;
messages: string[];
enabled: boolean;
name: string;
minimum_chat_activity: number;
minimum_delay: number;
messages: string[];
}
export interface TwitchChatTimersConfig {
timers: Record<string, TwitchChatTimer>;
timers: Record<string, TwitchChatTimer>;
}
export interface TwitchChatAlertsConfig {
follow: {
enabled: boolean;
messages: string[];
};
subscription: {
enabled: boolean;
messages: string[];
variations: {
min_streak?: number;
is_gifted?: boolean;
messages: string[];
}[];
};
gift_sub: {
enabled: boolean;
messages: string[];
variations: {
is_anonymous?: boolean;
min_cumulative?: number;
messages: string[];
}[];
};
raid: {
enabled: boolean;
messages: string[];
variations: {
min_viewers?: number;
messages: string[];
}[];
};
cheer: {
enabled: boolean;
messages: string[];
variations: {
min_amount?: number;
messages: string[];
}[];
};
follow: {
enabled: boolean;
messages: string[];
};
subscription: {
enabled: boolean;
messages: string[];
variations: {
min_streak?: number;
is_gifted?: boolean;
messages: string[];
}[];
};
gift_sub: {
enabled: boolean;
messages: string[];
variations: {
is_anonymous?: boolean;
min_cumulative?: number;
messages: string[];
}[];
};
raid: {
enabled: boolean;
messages: string[];
variations: {
min_viewers?: number;
messages: string[];
}[];
};
cheer: {
enabled: boolean;
messages: string[];
variations: {
min_amount?: number;
messages: string[];
}[];
};
}
export interface LoyaltyPointsEntry {
points: number;
points: number;
}
export type LoyaltyStorage = Record<string, LoyaltyPointsEntry>;
export interface LoyaltyReward {
enabled: boolean;
id: string;
name: string;
description: string;
image: string;
price: number;
required_info?: string;
cooldown: number;
enabled: boolean;
id: string;
name: string;
description: string;
image: string;
price: number;
required_info?: string;
cooldown: number;
}
export interface LoyaltyGoal {
enabled: boolean;
id: string;
name: string;
description: string;
image: string;
total: number;
contributed: number;
contributors: Record<string, number>;
enabled: boolean;
id: string;
name: string;
description: string;
image: string;
total: number;
contributed: number;
contributors: Record<string, number>;
}
export interface LoyaltyRedeem {
username: string;
display_name: string;
when: string | Date;
reward: LoyaltyReward;
request_text: string;
username: string;
display_name: string;
when: string | Date;
reward: LoyaltyReward;
request_text: string;
}
export interface UISettings {
onboardingStatus: number;
onboardingDone: boolean;
language: string;
theme: string;
hideViewers: boolean;
onboardingStatus: number;
onboardingDone: boolean;
language: string;
theme: string;
hideViewers: boolean;
}
export enum ConnectionStatus {
NotConnected,
AuthenticationNeeded,
Connected,
NotConnected = "not-connected",
AuthenticationNeeded = "auth-needed",
Connected = "connected",
}
export type RequestStatus =
| { type: 'pending'; updated: Date }
| { type: 'success'; updated: Date }
| { type: 'error'; updated: Date; error: string };
| { type: "pending"; updated: Date }
| { type: "success"; updated: Date }
| { type: "error"; updated: Date; error: string };
export interface APIState {
client: KilovoltWS;
connectionStatus: ConnectionStatus;
kvError: kvError;
initialLoadComplete: boolean;
loyalty: {
users: LoyaltyStorage;
rewards: LoyaltyReward[];
goals: LoyaltyGoal[];
redeemQueue: LoyaltyRedeem[];
};
twitchChat: {
commands: TwitchChatCustomCommands;
timers: TwitchChatTimersConfig;
alerts: TwitchChatAlertsConfig;
};
moduleConfigs: {
httpConfig: HTTPConfig;
twitchConfig: TwitchConfig;
twitchChatConfig: TwitchChatConfig;
loyaltyConfig: LoyaltyConfig;
};
uiConfig: UISettings;
requestStatus: Record<string, RequestStatus>;
client: KilovoltWS;
connectionStatus: ConnectionStatus;
kvError: kvError;
initialLoadComplete: boolean;
loyalty: {
users: LoyaltyStorage;
rewards: LoyaltyReward[];
goals: LoyaltyGoal[];
redeemQueue: LoyaltyRedeem[];
};
twitchChat: {
commands: TwitchChatCustomCommands;
timers: TwitchChatTimersConfig;
alerts: TwitchChatAlertsConfig;
};
moduleConfigs: {
httpConfig: HTTPConfig;
twitchConfig: TwitchConfig;
twitchChatConfig: TwitchChatConfig;
loyaltyConfig: LoyaltyConfig;
};
uiConfig: UISettings;
requestStatus: Record<string, RequestStatus>;
}

View File

@ -1,326 +1,317 @@
/* eslint-disable no-param-reassign */
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Extension } from '~/lib/extensions/extension';
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
import { Extension } from "~/lib/extensions/extension";
import {
ExtensionDependencies,
ExtensionOptions,
ExtensionRunOptions,
ExtensionStatus,
} from '~/lib/extensions/types';
import { ThunkConfig } from '..';
import { HTTPConfig } from '../api/types';
type ExtensionDependencies,
type ExtensionOptions,
type ExtensionRunOptions,
ExtensionStatus,
} from "~/lib/extensions/types";
import type { ThunkConfig } from "..";
import type { HTTPConfig } from "../api/types";
interface ExtensionsState {
ready: boolean;
installed: Record<string, ExtensionEntry>;
running: Record<string, Extension>;
unsaved: Record<string, string>;
status: Record<string, ExtensionStatus>;
editorCurrentFile: string;
dependencies: ExtensionDependencies;
ready: boolean;
installed: Record<string, ExtensionEntry>;
running: Record<string, Extension>;
unsaved: Record<string, string>;
status: Record<string, ExtensionStatus>;
editorCurrentFile: string;
dependencies: ExtensionDependencies;
}
export interface ExtensionEntry {
name: string;
source: string;
options: ExtensionOptions;
name: string;
source: string;
options: ExtensionOptions;
}
const initialState: ExtensionsState = {
ready: false,
installed: {},
running: {},
unsaved: {},
editorCurrentFile: null,
status: {},
dependencies: {
kilovolt: { address: '' },
},
ready: false,
installed: {},
running: {},
unsaved: {},
editorCurrentFile: null,
status: {},
dependencies: {
kilovolt: { address: "" },
},
};
const extensionsReducer = createSlice({
name: 'extensions',
initialState,
reducers: {
initialized(state, { payload }: PayloadAction<ExtensionDependencies>) {
state.dependencies = payload;
state.ready = true;
},
editorSelectedFile(state, { payload }: PayloadAction<string>) {
state.editorCurrentFile = payload;
},
extensionDrafted(state, { payload }: PayloadAction<ExtensionEntry>) {
state.unsaved[payload.name] = payload.source;
name: "extensions",
initialState,
reducers: {
initialized(state, { payload }: PayloadAction<ExtensionDependencies>) {
state.dependencies = payload;
state.ready = true;
},
editorSelectedFile(state, { payload }: PayloadAction<string>) {
state.editorCurrentFile = payload;
},
extensionDrafted(state, { payload }: PayloadAction<ExtensionEntry>) {
state.unsaved[payload.name] = payload.source;
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
},
extensionSourceChanged(state, { payload }: PayloadAction<string>) {
state.unsaved[state.editorCurrentFile] = payload;
},
extensionStatusChanged(
state,
{ payload }: PayloadAction<{ name: string; status: ExtensionStatus }>,
) {
state.status[payload.name] = payload.status;
},
extensionAdded(state, { payload }: PayloadAction<ExtensionEntry>) {
// Remove from unsaved
if (payload.name in state.unsaved) {
delete state.unsaved[payload.name];
}
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
},
extensionSourceChanged(state, { payload }: PayloadAction<string>) {
state.unsaved[state.editorCurrentFile] = payload;
},
extensionStatusChanged(
state,
{ payload }: PayloadAction<{ name: string; status: ExtensionStatus }>,
) {
state.status[payload.name] = payload.status;
},
extensionAdded(state, { payload }: PayloadAction<ExtensionEntry>) {
// Remove from unsaved
if (payload.name in state.unsaved) {
delete state.unsaved[payload.name];
}
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
state.installed[payload.name] = payload;
},
extensionInstanceAdded(state, { payload }: PayloadAction<Extension>) {
// If running, terminate running instance
if (payload.info.name in state.running) {
state.running[payload.info.name]?.dispose();
}
state.installed[payload.name] = payload;
},
extensionInstanceAdded(state, { payload }: PayloadAction<Extension>) {
// If running, terminate running instance
if (payload.info.name in state.running) {
state.running[payload.info.name]?.dispose();
}
// Create new instance with stored code
state.status[payload.info.name] = ExtensionStatus.GettingReady;
state.running[payload.info.name] = payload;
},
extensionRemoved(state, { payload }: PayloadAction<string>) {
// If running, terminate running instance
if (payload in state.running) {
state.running[payload]?.dispose();
}
// Create new instance with stored code
state.status[payload.info.name] = ExtensionStatus.GettingReady;
state.running[payload.info.name] = payload;
},
extensionRemoved(state, { payload }: PayloadAction<string>) {
// If running, terminate running instance
if (payload in state.running) {
state.running[payload]?.dispose();
}
// Remove from other lists
delete state.installed[payload];
delete state.running[payload];
delete state.unsaved[payload];
delete state.status[payload];
// Remove from other lists
delete state.installed[payload];
delete state.running[payload];
delete state.unsaved[payload];
delete state.status[payload];
// If it's the currently selected file in the editor, select another or none
if (state.editorCurrentFile === payload) {
const others = Object.keys(state.installed);
state.editorCurrentFile = others.length > 0 ? others[0] : null;
}
},
},
// If it's the currently selected file in the editor, select another or none
if (state.editorCurrentFile === payload) {
const others = Object.keys(state.installed);
state.editorCurrentFile = others.length > 0 ? others[0] : null;
}
},
},
});
const extensionPrefix = 'ui/extensions/installed/';
const extensionPrefix = "ui/extensions/installed/";
export const createExtensionInstance = createAsyncThunk<
void,
{
entry: ExtensionEntry;
dependencies: ExtensionDependencies;
runOptions?: ExtensionRunOptions;
},
ThunkConfig
>('extensions/new-instance', (payload, { dispatch }) => {
const ext = new Extension(
payload.entry,
payload.dependencies,
payload.runOptions,
);
ext.addEventListener('statusChanged', (ev: CustomEvent<ExtensionStatus>) => {
dispatch(
extensionsReducer.actions.extensionStatusChanged({
name: payload.entry.name,
status: ev.detail,
}),
);
});
dispatch(extensionsReducer.actions.extensionAdded(payload.entry));
dispatch(extensionsReducer.actions.extensionInstanceAdded(ext));
void,
{
entry: ExtensionEntry;
dependencies: ExtensionDependencies;
runOptions?: ExtensionRunOptions;
},
ThunkConfig
>("extensions/new-instance", (payload, { dispatch }) => {
const ext = new Extension(payload.entry, payload.dependencies, payload.runOptions);
ext.addEventListener("statusChanged", (ev: CustomEvent<ExtensionStatus>) => {
dispatch(
extensionsReducer.actions.extensionStatusChanged({
name: payload.entry.name,
status: ev.detail,
}),
);
});
dispatch(extensionsReducer.actions.extensionAdded(payload.entry));
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',
async (_, { getState, dispatch }) => {
// Get kv client
const { api } = getState();
"extensions/initialize",
async (_, { getState, dispatch }) => {
// Get kv client
const { api } = getState();
// Get kilovolt endpoint/credentials
const httpConfig = await api.client.getJSON<HTTPConfig>('http/config');
// Get kilovolt endpoint/credentials
const httpConfig = await api.client.getJSON<HTTPConfig>("http/config");
// Set dependencies
const deps = {
kilovolt: {
address: `ws://${httpConfig.bind}/ws`,
password: httpConfig.kv_password,
},
};
dispatch(extensionsReducer.actions.initialized(deps));
// Set dependencies
const deps = {
kilovolt: {
address: `ws://${httpConfig.bind}/ws`,
password: httpConfig.kv_password,
},
};
dispatch(extensionsReducer.actions.initialized(deps));
// Become reactive to extension changes
await api.client.subscribePrefix(extensionPrefix, (newValue, newKey) => {
const name = newKey.substring(extensionPrefix.length);
// Check for deleted
if (!newValue) {
void dispatch(extensionsReducer.actions.extensionRemoved(name));
return;
}
void dispatch(
refreshExtensionInstance({
...(JSON.parse(newValue) as ExtensionEntry),
name,
}),
);
});
// Become reactive to extension changes
await api.client.subscribePrefix(extensionPrefix, (newValue, newKey) => {
const name = newKey.substring(extensionPrefix.length);
// Check for deleted
if (!newValue) {
void dispatch(extensionsReducer.actions.extensionRemoved(name));
return;
}
void dispatch(
refreshExtensionInstance({
...(JSON.parse(newValue) as ExtensionEntry),
name,
}),
);
});
// Get installed extensions
const installed = await api.client.getKeysByPrefix(extensionPrefix);
await Promise.all(
Object.entries(installed).map(async ([extName, extContent]) => {
const entry = {
...(JSON.parse(extContent) as ExtensionEntry),
name: extName.substring(extensionPrefix.length),
};
if (entry.options.enabled) {
await dispatch(
createExtensionInstance({
entry,
dependencies: deps,
}),
);
} else {
dispatch(extensionsReducer.actions.extensionAdded(entry));
}
}),
);
},
// Get installed extensions
const installed = await api.client.getKeysByPrefix(extensionPrefix);
await Promise.all(
Object.entries(installed).map(async ([extName, extContent]) => {
const entry = {
...(JSON.parse(extContent) as ExtensionEntry),
name: extName.substring(extensionPrefix.length),
};
if (entry.options.enabled) {
await dispatch(
createExtensionInstance({
entry,
dependencies: deps,
}),
);
} else {
dispatch(extensionsReducer.actions.extensionAdded(entry));
}
}),
);
},
);
export const startExtension = createAsyncThunk<void, string, ThunkConfig>(
'extensions/start',
async (name, { getState, dispatch }) => {
const { extensions } = getState();
"extensions/start",
async (name, { getState, dispatch }) => {
const { extensions } = getState();
// If terminated, re-create extension
if (extensions.running[name].status === ExtensionStatus.Terminated) {
await dispatch(
createExtensionInstance({
entry: extensions.installed[name],
dependencies: extensions.dependencies,
runOptions: { autostart: true },
}),
);
return;
}
// If terminated, re-create extension
if (extensions.running[name].status === ExtensionStatus.Terminated) {
await dispatch(
createExtensionInstance({
entry: extensions.installed[name],
dependencies: extensions.dependencies,
runOptions: { autostart: true },
}),
);
return;
}
extensions.running[name].start();
},
extensions.running[name].start();
},
);
export const stopExtension = createAsyncThunk<void, string, ThunkConfig>(
'extensions/stop',
(name, { getState }) => {
const { extensions } = getState();
extensions.running[name].stop();
},
"extensions/stop",
(name, { getState }) => {
const { extensions } = getState();
extensions.running[name].stop();
},
);
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.editorCurrentFile in ext.unsaved &&
ext.unsaved[ext.editorCurrentFile] !== ext.installed[ext.editorCurrentFile]?.source;
export const currentFile = (ext: ExtensionsState) =>
isUnsaved(ext)
? ext.unsaved[ext.editorCurrentFile]
: ext.installed[ext.editorCurrentFile]?.source;
isUnsaved(ext)
? ext.unsaved[ext.editorCurrentFile]
: ext.installed[ext.editorCurrentFile]?.source;
export const saveCurrentExtension = createAsyncThunk<void, void, ThunkConfig>(
'extensions/save-current',
async (_, { getState, dispatch }) => {
const { extensions } = getState();
if (!isUnsaved(extensions)) {
return;
}
await dispatch(
saveExtension({
name: extensions.editorCurrentFile,
source: currentFile(extensions),
options:
extensions.editorCurrentFile in extensions.installed
? extensions.installed[extensions.editorCurrentFile].options
: { enabled: false },
}),
);
},
"extensions/save-current",
async (_, { getState, dispatch }) => {
const { extensions } = getState();
if (!isUnsaved(extensions)) {
return;
}
await dispatch(
saveExtension({
name: extensions.editorCurrentFile,
source: currentFile(extensions),
options:
extensions.editorCurrentFile in extensions.installed
? extensions.installed[extensions.editorCurrentFile].options
: { enabled: false },
}),
);
},
);
export const removeExtension = createAsyncThunk<void, string, ThunkConfig>(
'extensions/remove',
async (name, { getState }) => {
// Get kv client
const { api } = getState();
await api.client.deleteKey(extensionPrefix + name);
},
"extensions/remove",
async (name, { getState }) => {
// Get kv client
const { api } = getState();
await api.client.deleteKey(extensionPrefix + name);
},
);
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,24 +1,24 @@
import { configureStore } from '@reduxjs/toolkit';
import { EqualityFn, useDispatch, useSelector } from 'react-redux';
import { thunk } from 'redux-thunk';
import { configureStore } from "@reduxjs/toolkit";
import { type EqualityFn, useDispatch, useSelector } from "react-redux";
import { thunk } from "redux-thunk";
import apiReducer from './api/reducer';
import loggingReducer from './logging/reducer';
import extensionsReducer from './extensions/reducer';
import serverReducer from './server/reducer';
import apiReducer from "./api/reducer";
import loggingReducer from "./logging/reducer";
import extensionsReducer from "./extensions/reducer";
import serverReducer from "./server/reducer";
const store = configureStore({
reducer: {
api: apiReducer.reducer,
logging: loggingReducer.reducer,
extensions: extensionsReducer.reducer,
server: serverReducer.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(thunk),
devTools: true,
reducer: {
api: apiReducer.reducer,
logging: loggingReducer.reducer,
extensions: extensionsReducer.reducer,
server: serverReducer.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(thunk),
devTools: true,
});
export type RootState = ReturnType<typeof store.getState>;
@ -26,8 +26,8 @@ export type AppDispatch = typeof store.dispatch;
export type ThunkConfig = { state: RootState };
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: <Selected = unknown>(
selector: (state: RootState) => Selected,
equalityFn?: EqualityFn<Selected> | undefined,
selector: (state: RootState) => Selected,
equalityFn?: EqualityFn<Selected> | undefined,
) => Selected = useSelector;
export default store;

View File

@ -1,64 +1,55 @@
/* 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;
time: Date;
level: string;
message: string;
data: object;
id: string;
time: Date;
level: string;
message: string;
data: object;
}
export function processEntry({
id,
time,
level,
message,
data,
}: main.LogEntry): ProcessedLogEntry {
return {
id,
time: new Date(time),
level,
message,
data: JSON.parse(data) as object,
};
export function processEntry({ id, time, level, message, data }: main.LogEntry): ProcessedLogEntry {
return {
id,
time: new Date(time),
level,
message,
data: JSON.parse(data) as object,
};
}
interface LoggingState {
messages: ProcessedLogEntry[];
messages: ProcessedLogEntry[];
}
const initialState: LoggingState = {
messages: [],
messages: [],
};
const keyfn = (ev: main.LogEntry) => ev.id;
const loggingReducer = createSlice({
name: 'logging',
initialState,
reducers: {
loadedLogData(state, { payload }: PayloadAction<main.LogEntry[]>) {
const logKeys = payload.map(keyfn);
name: "logging",
initialState,
reducers: {
loadedLogData(state, { payload }: PayloadAction<main.LogEntry[]>) {
const logKeys = payload.map(keyfn);
// Clean up duplicates before setting to state
const uniqueLogs = payload.filter(
(ev, pos) => logKeys.indexOf(keyfn(ev)) === pos,
);
// Clean up duplicates before setting to state
const uniqueLogs = payload.filter((ev, pos) => logKeys.indexOf(keyfn(ev)) === pos);
state.messages = uniqueLogs
.map(processEntry)
.sort((a, b) => a.time.getTime() - b.time.getTime());
},
receivedEvent(state, { payload }: PayloadAction<main.LogEntry>) {
state.messages.push(processEntry(payload));
},
clearedEvents(state) {
state.messages = [];
},
},
state.messages = uniqueLogs
.map(processEntry)
.sort((a, b) => a.time.getTime() - b.time.getTime());
},
receivedEvent(state, { payload }: PayloadAction<main.LogEntry>) {
state.messages.push(processEntry(payload));
},
clearedEvents(state) {
state.messages = [];
},
},
});
export default loggingReducer;

View File

@ -1,31 +1,31 @@
/* eslint-disable no-param-reassign */
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { GetAppVersion } from '@wailsapp/go/main/App';
import { main } from '@wailsapp/go/models';
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
import { GetAppVersion } from "@wailsapp/go/main/App";
import type { main } from "@wailsapp/go/models";
interface ServerState {
version: main.VersionInfo;
version: main.VersionInfo;
}
const initialState: ServerState = {
version: null,
version: null,
};
const serverReducer = createSlice({
name: 'server',
initialState,
reducers: {
loadedVersionData(state, { payload }: PayloadAction<main.VersionInfo>) {
state.version = payload;
},
},
name: "server",
initialState,
reducers: {
loadedVersionData(state, { payload }: PayloadAction<main.VersionInfo>) {
state.version = payload;
},
},
});
export const initializeServerInfo = createAsyncThunk(
'server/init-info',
async (_: void, { dispatch }) => {
dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion()));
},
"server/init-info",
async (_: never, { dispatch }) => {
dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion()));
},
);
export default serverReducer;

View File

@ -1,297 +1,282 @@
import {
ChatBubbleIcon,
CodeIcon,
DashboardIcon,
FrameIcon,
MixerHorizontalIcon,
MixIcon,
StarIcon,
TableIcon,
TimerIcon,
} from '@radix-ui/react-icons';
import { EventsOff, EventsOn } from '@wailsapp/runtime/runtime';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
ChatBubbleIcon,
CodeIcon,
DashboardIcon,
FrameIcon,
MixerHorizontalIcon,
MixIcon,
StarIcon,
TableIcon,
TimerIcon,
} from "@radix-ui/react-icons";
import { EventsOff, EventsOn } from "@wailsapp/runtime/runtime";
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';
import { ConnectionStatus } from '~/store/api/types';
import loggingReducer from '~/store/logging/reducer';
import { initializeExtensions } from '~/store/extensions/reducer';
import { initializeServerInfo } from '~/store/server/reducer';
import { useAppDispatch, useAppSelector } from "~/store";
import { createWSClient, useAuthBypass } from "~/store/api/reducer";
import { ConnectionStatus } from "~/store/api/types";
import loggingReducer from "~/store/logging/reducer";
import { initializeExtensions } from "~/store/extensions/reducer";
import { initializeServerInfo } from "~/store/server/reducer";
import LogViewer from './components/LogViewer';
import Sidebar, { RouteSection } from './components/Sidebar';
import Scrollbar from './components/utils/Scrollbar';
import TwitchChatCommandsPage from './pages/twitch/ChatCommands';
import TwitchChatTimersPage from './pages/twitch/ChatTimers';
import ChatAlertsPage from './pages/twitch/ChatAlerts';
import Dashboard from './pages/Dashboard';
import DebugPage from './pages/system/Debug';
import LoyaltyConfigPage from './pages/loyalty/LoyaltyConfig';
import LoyaltyQueuePage from './pages/loyalty/LoyaltyQueue';
import LoyaltyRewardsPage from './pages/loyalty/Rewards/Page';
import OnboardingPage from './pages/Onboarding';
import ServerSettingsPage from './pages/system/ServerSettings';
import StrimertulPage from './pages/system/Strimertul';
import TwitchSettingsPage from './pages/twitch/TwitchSettings/Page';
import UISettingsPage from './pages/system/UISettingsPage';
import ExtensionsPage from './pages/system/Extensions';
import { getTheme, styled } from './theme';
import Loading from './components/Loading';
import InteractiveAuthDialog from './components/InteractiveAuthDialog';
import LogViewer from "./components/LogViewer";
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";
import ChatAlertsPage from "./pages/twitch/ChatAlerts";
import Dashboard from "./pages/Dashboard";
import DebugPage from "./pages/system/Debug";
import LoyaltyConfigPage from "./pages/loyalty/LoyaltyConfig";
import LoyaltyQueuePage from "./pages/loyalty/LoyaltyQueue";
import LoyaltyRewardsPage from "./pages/loyalty/Rewards/Page";
import OnboardingPage from "./pages/Onboarding";
import ServerSettingsPage from "./pages/system/ServerSettings";
import StrimertulPage from "./pages/system/Strimertul";
import TwitchSettingsPage from "./pages/twitch/TwitchSettings/Page";
import UISettingsPage from "./pages/system/UISettingsPage";
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[] = [
{
title: 'menu.sections.monitor',
short: 'menu.sections.monitor-short',
links: [
{
title: 'menu.pages.monitor.dashboard',
url: '/',
icon: <DashboardIcon />,
},
],
},
{
title: 'menu.sections.strimertul',
short: 'menu.sections.strimertul-short',
links: [
{
title: 'menu.pages.strimertul.settings',
url: '/http',
icon: <MixerHorizontalIcon />,
},
{
title: 'menu.pages.strimertul.ui-config',
url: '/ui-config',
icon: <MixIcon />,
},
{
title: 'menu.pages.strimertul.extensions',
url: '/extensions',
icon: <CodeIcon />,
},
],
},
{
title: 'menu.sections.twitch',
short: 'menu.sections.twitch-short',
links: [
{
title: 'menu.pages.twitch.configuration',
url: '/twitch/settings',
icon: <MixerHorizontalIcon />,
},
{
title: 'menu.pages.twitch.chat-commands',
url: '/twitch/chat/commands',
icon: <ChatBubbleIcon />,
},
{
title: 'menu.pages.twitch.chat-timers',
url: '/twitch/chat/timers',
icon: <TimerIcon />,
},
{
title: 'menu.pages.twitch.chat-alerts',
url: '/twitch/chat/alerts',
icon: <FrameIcon />,
},
],
},
{
title: 'menu.sections.loyalty',
short: 'menu.sections.loyalty-short',
links: [
{
title: 'menu.pages.loyalty.configuration',
url: '/loyalty/settings',
icon: <MixerHorizontalIcon />,
},
{
title: 'menu.pages.loyalty.points',
url: '/loyalty/users',
icon: <TableIcon />,
},
{
title: 'menu.pages.loyalty.rewards',
url: '/loyalty/rewards',
icon: <StarIcon />,
},
],
},
{
title: "menu.sections.monitor",
short: "menu.sections.monitor-short",
links: [
{
title: "menu.pages.monitor.dashboard",
url: "/",
icon: <DashboardIcon />,
},
],
},
{
title: "menu.sections.strimertul",
short: "menu.sections.strimertul-short",
links: [
{
title: "menu.pages.strimertul.settings",
url: "/http",
icon: <MixerHorizontalIcon />,
},
{
title: "menu.pages.strimertul.ui-config",
url: "/ui-config",
icon: <MixIcon />,
},
{
title: "menu.pages.strimertul.extensions",
url: "/extensions",
icon: <CodeIcon />,
},
],
},
{
title: "menu.sections.twitch",
short: "menu.sections.twitch-short",
links: [
{
title: "menu.pages.twitch.configuration",
url: "/twitch/settings",
icon: <MixerHorizontalIcon />,
},
{
title: "menu.pages.twitch.chat-commands",
url: "/twitch/chat/commands",
icon: <ChatBubbleIcon />,
},
{
title: "menu.pages.twitch.chat-timers",
url: "/twitch/chat/timers",
icon: <TimerIcon />,
},
{
title: "menu.pages.twitch.chat-alerts",
url: "/twitch/chat/alerts",
icon: <FrameIcon />,
},
],
},
{
title: "menu.sections.loyalty",
short: "menu.sections.loyalty-short",
links: [
{
title: "menu.pages.loyalty.configuration",
url: "/loyalty/settings",
icon: <MixerHorizontalIcon />,
},
{
title: "menu.pages.loyalty.points",
url: "/loyalty/users",
icon: <TableIcon />,
},
{
title: "menu.pages.loyalty.rewards",
url: "/loyalty/rewards",
icon: <StarIcon />,
},
],
},
];
const Container = styled('div', {
position: 'relative',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
height: '100vh',
backgroundColor: '$gray1',
color: '$gray12',
const Container = styled("div", {
position: "relative",
display: "flex",
flexDirection: "row",
overflow: "hidden",
height: "100vh",
backgroundColor: "$gray1",
color: "$gray12",
});
const PageContent = styled('main', {
flex: 1,
overflow: 'auto',
const PageContent = styled("main", {
flex: 1,
overflow: "auto",
});
const PageWrapper = styled('div', {
display: 'flex',
flexDirection: 'row',
flex: 1,
overflow: 'hidden',
const PageWrapper = styled("div", {
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
});
export default function App(): JSX.Element {
const [ready, setReady] = useState(false);
const client = useAppSelector((state) => state.api.client);
const uiConfig = useAppSelector((state) => state.api.uiConfig);
const connected = useAppSelector((state) => state.api.connectionStatus);
const dispatch = useAppDispatch();
const location = useLocation();
const navigate = useNavigate();
const [t, i18n] = useTranslation();
const [ready, setReady] = useState(false);
const client = useKilovoltClient();
const uiConfig = useAppSelector((state) => state.api.uiConfig);
const connected = useAppSelector((state) => state.api.connectionStatus);
const dispatch = useAppDispatch();
const location = useLocation();
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
const lang = localStorage.getItem("language");
if (lang) {
void i18n.changeLanguage(lang);
}
}, []);
// Fill application info
useEffect(() => {
void dispatch(initializeServerInfo());
// Load language from local storage until db is ready
const lang = localStorage.getItem('language');
if (lang) {
void i18n.changeLanguage(lang);
}
}, []);
// Get application logs
useEffect(() => {
void GetLastLogs().then((logs) => {
dispatch(loggingReducer.actions.loadedLogData(logs));
});
EventsOn("log-event", (event: main.LogEntry) => {
dispatch(loggingReducer.actions.receivedEvent(event));
});
return () => {
EventsOff("log-event");
};
}, []);
// Get application logs
useEffect(() => {
void GetLastLogs().then((logs) => {
dispatch(loggingReducer.actions.loadedLogData(logs));
});
EventsOn('log-event', (event: main.LogEntry) => {
dispatch(loggingReducer.actions.receivedEvent(event));
});
return () => {
EventsOff('log-event');
};
}, []);
// Wait for main process to give us the OK to hit kilovolt
useEffect(() => {
void IsServerReady().then(setReady);
EventsOn("ready", (newValue: boolean) => {
setReady(newValue);
});
return () => {
EventsOff("ready");
};
}, []);
// Wait for main process to give us the OK to hit kilovolt
useEffect(() => {
void IsServerReady().then(setReady);
EventsOn('ready', (newValue: boolean) => {
setReady(newValue);
});
return () => {
EventsOff('ready');
};
}, []);
// Connect to kilovolt as soon as it's available
useEffect(() => {
const connectToKV = async () => {
const address = await GetKilovoltBind();
await dispatch(
createWSClient({
address: `ws://${address}/ws`,
}),
);
};
// Connect to kilovolt as soon as it's available
useEffect(() => {
if (!ready) {
return;
}
if (!client) {
void connectToKV();
return;
}
if (connected === ConnectionStatus.AuthenticationNeeded) {
// If Kilovolt is protected by password (pretty much always) use the bypass
void dispatch(useAuthBypass());
return;
}
if (connected === ConnectionStatus.Connected) {
// Once connected, initialize UI subsystems
void dispatch(initializeExtensions());
}
}, [ready, connected]);
if (!ready) {
return;
}
if (!client) {
void connectToKV();
return;
}
if (connected === ConnectionStatus.AuthenticationNeeded) {
// If Kilovolt is protected by password (pretty much always) use the bypass
void dispatch(useAuthBypass());
return;
}
if (connected === ConnectionStatus.Connected) {
// Once connected, initialize UI subsystems
void dispatch(initializeExtensions());
}
}, [ready, connected]);
// Sync UI changes on key change
useEffect(() => {
if (uiConfig?.language) {
void i18n.changeLanguage(uiConfig.language ?? 'en');
localStorage.setItem('language', uiConfig.language);
}
if (uiConfig?.theme) {
localStorage.setItem('theme', uiConfig.theme);
}
if (!uiConfig?.onboardingDone) {
navigate('/setup');
}
}, [ready, uiConfig]);
// Sync UI changes on key change
// biome-ignore lint/correctness/useExhaustiveDependencies: False positive
useEffect(() => {
if (uiConfig?.language) {
void i18n.changeLanguage(uiConfig.language ?? "en");
localStorage.setItem("language", uiConfig.language);
}
if (uiConfig?.theme) {
localStorage.setItem("theme", uiConfig.theme);
}
if (!uiConfig?.onboardingDone) {
navigate("/setup");
}
}, [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')} />
);
}
if (
connected === ConnectionStatus.NotConnected ||
connected === ConnectionStatus.AuthenticationNeeded
) {
return <Loading theme={theme} size="fullscreen" message={t("special.loading")} />;
}
const showSidebar = location.pathname !== '/setup';
const showSidebar = location.pathname !== "/setup";
return (
<Container id="app-container" className={theme}>
<InteractiveAuthDialog />
<LogViewer />
{showSidebar ? <Sidebar sections={sections} /> : null}
<Scrollbar
vertical={true}
root={{ flex: 1 }}
viewport={{ height: '100vh', flex: '1' }}
>
<PageContent>
<PageWrapper role="main">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/setup" element={<OnboardingPage />} />
<Route path="/about" element={<StrimertulPage />} />
<Route path="/debug" element={<DebugPage />} />
<Route path="/http" element={<ServerSettingsPage />} />
<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/alerts" element={<ChatAlertsPage />} />
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
<Route path="/loyalty/rewards" element={<LoyaltyRewardsPage />} />
</Routes>
</PageWrapper>
</PageContent>
</Scrollbar>
</Container>
);
return (
<Container id="app-container" className={theme}>
<InteractiveAuthDialog />
<LogViewer />
{showSidebar ? <Sidebar sections={sections} /> : null}
<Scrollbar vertical={true} root={{ flex: 1 }} viewport={{ height: "100vh", flex: "1" }}>
<PageContent>
<PageWrapper role="main">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/setup" element={<OnboardingPage />} />
<Route path="/about" element={<StrimertulPage />} />
<Route path="/debug" element={<DebugPage />} />
<Route path="/http" element={<ServerSettingsPage />} />
<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/alerts" element={<ChatAlertsPage />} />
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
<Route path="/loyalty/rewards" element={<LoyaltyRewardsPage />} />
</Routes>
</PageWrapper>
</PageContent>
</Scrollbar>
</Container>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,70 @@
import React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { VariantProps } from '@stitches/react';
import { useTranslation } from 'react-i18next';
import React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type { VariantProps } from "@stitches/react";
import { useTranslation } from "react-i18next";
import {
AlertOverlay,
AlertContainer,
AlertTitle,
AlertDescription,
AlertActions,
AlertAction,
AlertCancel,
} from '../theme/alert';
import { Button } from '../theme';
AlertOverlay,
AlertContainer,
AlertTitle,
AlertDescription,
AlertActions,
AlertAction,
AlertCancel,
} from "../theme/alert";
import { Button } from "../theme";
export interface DialogProps {
title?: string;
description?: string;
actionText?: string;
showCancel?: boolean;
cancelText?: string;
actionButtonProps?: VariantProps<typeof Button>;
variation?: 'default' | 'danger';
onAction?: () => void;
title?: string;
description?: string;
actionText?: string;
showCancel?: boolean;
cancelText?: string;
actionButtonProps?: VariantProps<typeof Button>;
variation?: "default" | "danger";
onAction?: () => void;
}
function AlertContent({
title,
description,
children,
actionText,
actionButtonProps,
showCancel,
cancelText,
variation,
onAction,
title,
description,
children,
actionText,
actionButtonProps,
showCancel,
cancelText,
variation,
onAction,
}: React.PropsWithChildren<DialogProps>) {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<AlertDialogPrimitive.Portal
container={document.getElementById('app-container')}
>
<AlertOverlay />
<AlertContainer variation={variation ?? 'default'}>
{title && (
<AlertTitle variation={variation ?? 'default'}>{title}</AlertTitle>
)}
{description && (
<AlertDescription variation={variation ?? 'default'}>
{description}
</AlertDescription>
)}
{children}
<AlertActions>
<AlertAction asChild>
<Button
variation="primary"
{...actionButtonProps}
onClick={() => (onAction ? onAction() : null)}
>
{actionText || t('form-actions.ok')}
</Button>
</AlertAction>
{showCancel && (
<AlertCancel asChild>
<Button>{cancelText || t('form-actions.cancel')}</Button>
</AlertCancel>
)}
</AlertActions>
</AlertContainer>
</AlertDialogPrimitive.Portal>
);
return (
<AlertDialogPrimitive.Portal container={document.getElementById("app-container")}>
<AlertOverlay />
<AlertContainer variation={variation ?? "default"}>
{title && <AlertTitle variation={variation ?? "default"}>{title}</AlertTitle>}
{description && (
<AlertDescription variation={variation ?? "default"}>{description}</AlertDescription>
)}
{children}
<AlertActions>
<AlertAction asChild>
<Button
variation="primary"
{...actionButtonProps}
onClick={() => (onAction ? onAction() : null)}
>
{actionText || t("form-actions.ok")}
</Button>
</AlertAction>
{showCancel && (
<AlertCancel asChild>
<Button>{cancelText || t("form-actions.cancel")}</Button>
</AlertCancel>
)}
</AlertActions>
</AlertContainer>
</AlertDialogPrimitive.Portal>
);
}
const PureAlertContent = React.memo(AlertContent);

View File

@ -1,22 +1,22 @@
import React from 'react';
import { BrowserOpenURL } from '@wailsapp/runtime';
import React from "react";
import { BrowserOpenURL } from "@wailsapp/runtime";
function BrowserLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
if (!props.href) {
return <a {...props}></a>;
}
if (!props.href) {
return <a {...props} />;
}
const properties = { ...props };
delete properties.href;
return (
<a
{...properties}
style={{ ...properties.style, cursor: 'pointer' }}
onClick={() => {
BrowserOpenURL(props.href);
}}
></a>
);
const properties = { ...props };
properties.href = undefined;
return (
<a
{...properties}
style={{ ...properties.style, cursor: "pointer" }}
onClick={() => {
BrowserOpenURL(props.href);
}}
/>
);
}
const PureBrowserLink = React.memo(BrowserLink);

View File

@ -1,138 +1,135 @@
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import React, { ReactElement, useState } from 'react';
import { SortFunction } from '~/lib/types';
import { styled } from '../theme';
import { Table, TableHeader } from '../theme/table';
import PageList from './PageList';
import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
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";
interface SortingOrder<T> {
key: keyof T;
order: 'asc' | 'desc';
key: keyof T;
order: "asc" | "desc";
}
export interface DataTableProps<T> {
data: T[];
columns: ({
title: string;
attr?: React.HTMLAttributes<HTMLTableCellElement>;
} & (
| {
sortable: true;
key: Extract<keyof T, string>;
}
| {
sortable: false;
key: string;
}
))[];
defaultSort: SortingOrder<T>;
rowComponent: (data: { data: T }) => ReactElement;
sort: (key: keyof T) => SortFunction<T>;
keyFunction: (data: T) => string;
data: T[];
columns: ({
title: string;
attr?: React.HTMLAttributes<HTMLTableCellElement>;
} & (
| {
sortable: true;
key: Extract<keyof T, string>;
}
| {
sortable: false;
key: string;
}
))[];
defaultSort: SortingOrder<T>;
rowComponent: (data: { data: T }) => ReactElement;
sort: (key: keyof T) => SortFunction<T>;
keyFunction: (data: T) => string;
}
const Sortable = styled('div', {
display: 'flex',
gap: '0.3rem',
alignItems: 'center',
cursor: 'pointer',
const Sortable = styled("div", {
display: "flex",
gap: "0.3rem",
alignItems: "center",
cursor: "pointer",
});
/**
* DataTable is a component that displays a list of data in a table format with sorting and pagination.
*/
export function DataTable<T>({
data,
columns,
defaultSort,
sort,
rowComponent,
keyFunction,
data,
columns,
defaultSort,
sort,
rowComponent,
keyFunction,
}: DataTableProps<T>): React.ReactElement {
const [entriesPerPage, setEntriesPerPage] = useState(15);
const [page, setPage] = useState(0);
const [sorting, setSorting] = useState<SortingOrder<T>>(defaultSort);
const [entriesPerPage, setEntriesPerPage] = useState(15);
const [page, setPage] = useState(0);
const [sorting, setSorting] = useState<SortingOrder<T>>(defaultSort);
const changeSort = (key: keyof T) => {
if (sorting.key === key) {
// Same key, swap sorting order
setSorting({
...sorting,
order: sorting.order === 'asc' ? 'desc' : 'asc',
});
} else {
// Different key, change to sort that key
setSorting({ ...sorting, key, order: 'asc' });
}
};
const changeSort = (key: keyof T) => {
if (sorting.key === key) {
// Same key, swap sorting order
setSorting({
...sorting,
order: sorting.order === "asc" ? "desc" : "asc",
});
} else {
// Different key, change to sort that key
setSorting({ ...sorting, key, order: "asc" });
}
};
const sortedEntries = data.slice(0);
const sortFn = sort(sorting.key);
if (sortFn) {
sortedEntries.sort((a, b) => {
const result = sortFn(a, b);
switch (sorting.order) {
case 'asc':
return result;
case 'desc':
return -result;
}
return 0;
});
}
const sortedEntries = data.slice(0);
const sortFn = sort(sorting.key);
if (sortFn) {
sortedEntries.sort((a, b) => {
const result = sortFn(a, b);
switch (sorting.order) {
case "asc":
return result;
case "desc":
return -result;
}
return 0;
});
}
const offset = page * entriesPerPage;
const paged = sortedEntries.slice(offset, offset + entriesPerPage);
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
const offset = page * entriesPerPage;
const paged = sortedEntries.slice(offset, offset + entriesPerPage);
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
const RowComponent = rowComponent;
const RowComponent = rowComponent;
return (
<>
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
<Table>
<thead>
<tr>
{columns.map(({ key, sortable, title, attr }) => (
<TableHeader {...(attr || {})} key={key}>
{sortable ? (
<Sortable onClick={() => changeSort(key as keyof T)}>
{title}
{sorting.key === key &&
(sorting.order === 'asc' ? (
<ChevronUpIcon />
) : (
<ChevronDownIcon />
))}
</Sortable>
) : (
title
)}
</TableHeader>
))}
</tr>
</thead>
<tbody>
{paged.map((entry) => (
<RowComponent key={keyFunction(entry)} data={entry} />
))}
</tbody>
</Table>
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
</>
);
return (
<>
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
<Table>
<thead>
<tr>
{columns.map(({ key, sortable, title, attr }) => (
<TableHeader {...(attr || {})} key={key}>
{sortable ? (
<Sortable onClick={() => changeSort(key as keyof T)}>
{title}
{sorting.key === key &&
(sorting.order === "asc" ? <ChevronUpIcon /> : <ChevronDownIcon />)}
</Sortable>
) : (
title
)}
</TableHeader>
))}
</tr>
</thead>
<tbody>
{paged.map((entry) => (
<RowComponent key={keyFunction(entry)} data={entry} />
))}
</tbody>
</Table>
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
</>
);
}

View File

@ -1,40 +1,40 @@
import React from 'react';
import { styled } from '../theme';
import React from "react";
import { styled } from "../theme";
const TableContainer = styled('table', {
borderRadius: '3px',
backgroundColor: '$gray2',
padding: '0.3rem',
margin: '0.5rem 0',
const TableContainer = styled("table", {
borderRadius: "3px",
backgroundColor: "$gray2",
padding: "0.3rem",
margin: "0.5rem 0",
});
const Term = styled('th', {
padding: '0.3rem 0.5rem',
textAlign: 'right',
color: '$teal11',
const Term = styled("th", {
padding: "0.3rem 0.5rem",
textAlign: "right",
color: "$teal11",
});
const Definition = styled('td', {
padding: '0.3rem 0.5rem',
const Definition = styled("td", {
padding: "0.3rem 0.5rem",
});
interface DefinitionTableProps {
entries: Record<string, string>;
entries: Record<string, string>;
}
function DefinitionTable({ entries }: DefinitionTableProps) {
return (
<TableContainer>
<tbody>
{Object.entries(entries).map(([key, value]) => (
<tr key={key}>
<Term>{key}</Term>
<Definition>{value}</Definition>
</tr>
))}
</tbody>
</TableContainer>
);
return (
<TableContainer>
<tbody>
{Object.entries(entries).map(([key, value]) => (
<tr key={key}>
<Term>{key}</Term>
<Definition>{value}</Definition>
</tr>
))}
</tbody>
</TableContainer>
);
}
const PureDefinitionTable = React.memo(DefinitionTable);

View File

@ -1,50 +1,48 @@
import React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import {
DialogOverlay,
DialogContainer,
IconButton,
DialogTitle,
DialogDescription,
} from '../theme';
DialogOverlay,
DialogContainer,
IconButton,
DialogTitle,
DialogDescription,
} from "../theme";
export interface DialogProps {
title?: string;
description?: string;
closeButton?: boolean;
title?: string;
description?: string;
closeButton?: boolean;
}
function DialogContent({
title,
description,
children,
closeButton,
title,
description,
children,
closeButton,
}: React.PropsWithChildren<DialogProps>) {
return (
<DialogPrimitive.Portal
container={document.getElementById('app-container')}
>
<DialogOverlay />
<DialogContainer>
{title && (
<DialogTitle>
{title}
return (
<DialogPrimitive.Portal container={document.getElementById("app-container")}>
<DialogOverlay />
<DialogContainer>
{title && (
<DialogTitle>
{title}
{closeButton && (
<DialogPrimitive.DialogClose asChild>
<IconButton>
<Cross2Icon />
</IconButton>
</DialogPrimitive.DialogClose>
)}
</DialogTitle>
)}
{description && <DialogDescription>{description}</DialogDescription>}
{children}
</DialogContainer>
</DialogPrimitive.Portal>
);
{closeButton && (
<DialogPrimitive.DialogClose asChild>
<IconButton>
<Cross2Icon />
</IconButton>
</DialogPrimitive.DialogClose>
)}
</DialogTitle>
)}
{description && <DialogDescription>{description}</DialogDescription>}
{children}
</DialogContainer>
</DialogPrimitive.Portal>
);
}
const PureDialogContent = React.memo(DialogContent);

View File

@ -1,169 +1,143 @@
import { useEffect, useState } from 'react';
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 BrowserLink from './BrowserLink';
import { useEffect, useState } from "react";
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 BrowserLink from "./BrowserLink";
interface AuthRequest {
uid: number;
info: AppInfo;
callbackID: string;
uid: number;
info: AppInfo;
callbackID: string;
}
interface AppInfo {
name: string;
author: string;
verificationCode: string;
url: string;
icon: string;
name: string;
author: string;
verificationCode: string;
url: string;
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',
backgroundColor: '$gray3',
padding: '0.5rem',
borderRadius: '5px',
gridTemplateColumns: '90px 1fr',
const AppCard = styled("div", {
display: "grid",
backgroundColor: "$gray3",
padding: "0.5rem",
borderRadius: "5px",
gridTemplateColumns: "90px 1fr",
});
const AppIcon = styled('img', {
maxWidth: '64px',
maxHeight: '64px',
gridColumn: '1',
gridRow: '1/5',
alignSelf: 'center',
justifySelf: 'center',
const AppIcon = styled("img", {
maxWidth: "64px",
maxHeight: "64px",
gridColumn: "1",
gridRow: "1/5",
alignSelf: "center",
justifySelf: "center",
});
const AppName = styled('div', {
fontWeight: 'bold',
fontSize: '18pt',
gridColumn: '2',
const AppName = styled("div", {
fontWeight: "bold",
fontSize: "18pt",
gridColumn: "2",
});
const AppInfo = styled('div', {
gridColumn: '2',
const AppInfo = styled("div", {
gridColumn: "2",
});
const AppCode = styled('div', {
fontFamily: 'monospace',
fontSize: '16pt',
backgroundColor: '$gray3',
padding: '0.2rem',
borderRadius: '5px',
gridTemplateColumns: '90px 1fr',
textAlign: 'center',
const AppCode = styled("div", {
fontFamily: "monospace",
fontSize: "16pt",
backgroundColor: "$gray3",
padding: "0.2rem",
borderRadius: "5px",
gridTemplateColumns: "90px 1fr",
textAlign: "center",
});
function parseAppInfo(message: Record<string, unknown>): AppInfo {
const info: AppInfo = {
name: '',
author: '',
verificationCode: '',
url: '',
icon: '',
};
const info: AppInfo = {
name: "",
author: "",
verificationCode: "",
url: "",
icon: "",
};
appInfoKeys.forEach((key) => {
if (key in message) {
info[key] = String(message[key]) || info[key];
}
});
for (const key of appInfoKeys) {
if (key in message) {
info[key] = String(message[key]) || info[key];
}
}
return info;
return info;
}
export default function InteractiveAuthDialog() {
const { t } = useTranslation();
const [requests, setRequests] = useState<AuthRequest[]>([]);
const { t } = useTranslation();
const [requests, setRequests] = useState<AuthRequest[]>([]);
useEffect(() => {
EventsOn(
'interactiveAuth',
(uid: number, message: Record<string, unknown>, callbackID: string) => {
setRequests([
...requests,
{ uid, info: parseAppInfo(message), callbackID },
]);
},
);
return () => {
EventsOff('interactiveAuth');
};
});
useEffect(() => {
EventsOn(
"interactiveAuth",
(uid: number, message: Record<string, unknown>, callbackID: string) => {
setRequests([...requests, { uid, info: parseAppInfo(message), callbackID }]);
},
);
return () => {
EventsOff("interactiveAuth");
};
});
const answerAuthRequest = (callbackID: string, answer: boolean) => {
EventsEmit(callbackID, answer);
setRequests(requests.filter((r) => r.callbackID !== callbackID));
};
const answerAuthRequest = (callbackID: string, answer: boolean) => {
EventsEmit(callbackID, answer);
setRequests(requests.filter((r) => r.callbackID !== callbackID));
};
return (
<>
{requests.map(({ uid, info, callbackID }) => (
<Dialog open={true} key={uid}>
<DialogContent title={t('pages.interactive-auth.title')}>
<DialogDescription css={{ color: '$gray12' }}>
<TextBlock>{t('pages.interactive-auth.desc-1')}</TextBlock>
<TextBlock css={{ fontWeight: 'bold', color: '$red11' }}>
{t('pages.interactive-auth.warn-1')}
</TextBlock>
<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>
{info.author && <AppInfo>{info.author}</AppInfo>}
{info.url && (
<AppInfo>
<BrowserLink href={info.url}>{info.url}</BrowserLink>
</AppInfo>
)}
</AppCard>
{info.verificationCode && (
<>
<TextBlock>
{t('pages.interactive-auth.verification-code')}
</TextBlock>
<AppCode>{info.verificationCode}</AppCode>
</>
)}
</DialogDescription>
<DialogActions>
<Button
variation="primary"
onClick={() => answerAuthRequest(callbackID, true)}
>
<CheckCircledIcon />
{t('pages.interactive-auth.allow')}
</Button>
<Button
variation="danger"
onClick={() => answerAuthRequest(callbackID, false)}
>
<CrossCircledIcon />
{t('pages.interactive-auth.deny')}
</Button>
</DialogActions>
</DialogContent>
</Dialog>
))}
</>
);
return (
<>
{requests.map(({ uid, info, callbackID }) => (
<Dialog open={true} key={uid}>
<DialogContent title={t("pages.interactive-auth.title")}>
<DialogDescription css={{ color: "$gray12" }}>
<TextBlock>{t("pages.interactive-auth.desc-1")}</TextBlock>
<TextBlock css={{ fontWeight: "bold", color: "$red11" }}>
{t("pages.interactive-auth.warn-1")}
</TextBlock>
<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>
{info.author && <AppInfo>{info.author}</AppInfo>}
{info.url && (
<AppInfo>
<BrowserLink href={info.url}>{info.url}</BrowserLink>
</AppInfo>
)}
</AppCard>
{info.verificationCode && (
<>
<TextBlock>{t("pages.interactive-auth.verification-code")}</TextBlock>
<AppCode>{info.verificationCode}</AppCode>
</>
)}
</DialogDescription>
<DialogActions>
<Button variation="primary" onClick={() => answerAuthRequest(callbackID, true)}>
<CheckCircledIcon />
{t("pages.interactive-auth.allow")}
</Button>
<Button variation="danger" onClick={() => answerAuthRequest(callbackID, false)}>
<CrossCircledIcon />
{t("pages.interactive-auth.deny")}
</Button>
</DialogActions>
</DialogContent>
</Dialog>
))}
</>
);
}

View File

@ -1,53 +1,49 @@
import React from 'react';
import type React from "react";
// @ts-expect-error Asset import
import spinner from '~/assets/icon-loading.svg';
import spinner from "~/assets/icon-loading.svg";
import { lightMode, styled, TextBlock } from '../theme';
import { lightMode, styled, TextBlock } from "../theme";
const variants = {
size: {
fullscreen: {
minHeight: '100vh',
},
fill: {
flex: '1',
height: '100%',
},
},
size: {
fullscreen: {
minHeight: "100vh",
},
fill: {
flex: "1",
height: "100%",
},
},
};
const LoadingDiv = styled('div', {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '$gray1',
color: '$gray12',
variants,
const LoadingDiv = styled("div", {
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
backgroundColor: "$gray1",
color: "$gray12",
variants,
});
const Spinner = styled('img', {
maxWidth: '100px',
[`.${lightMode} &`]: {
filter: 'invert(0.5) sepia(100%) hue-rotate(140deg);',
},
const Spinner = styled("img", {
maxWidth: "100px",
[`.${lightMode} &`]: {
filter: "invert(0.5) sepia(100%) hue-rotate(140deg);",
},
});
interface LoadingProps {
size?: keyof typeof variants.size;
message: string;
theme: string;
size?: keyof typeof variants.size;
message: string;
theme: string;
}
export default function Loading({
message,
size,
theme,
}: React.PropsWithChildren<LoadingProps>) {
return (
<LoadingDiv size={size} className={theme}>
<Spinner src={spinner as string} alt="Loading..." />
<TextBlock>{message}</TextBlock>
</LoadingDiv>
);
export default function Loading({ message, size, theme }: React.PropsWithChildren<LoadingProps>) {
return (
<LoadingDiv size={size} className={theme}>
<Spinner src={spinner as string} alt="Loading..." />
<TextBlock>{message}</TextBlock>
</LoadingDiv>
);
}

View File

@ -1,494 +1,481 @@
import { ClipboardCopyIcon, Cross2Icon, SizeIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
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 { ClipboardCopyIcon, Cross2Icon, SizeIcon } from "@radix-ui/react-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "src/store";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { delay } from "~/lib/time";
import type { ProcessedLogEntry } from "~/store/logging/reducer";
import {
Dialog,
DialogContainer,
DialogOverlay,
DialogTitle,
IconButton,
MultiToggle,
MultiToggleItem,
lightMode,
styled,
theme,
} from '../theme';
import Scrollbar from './utils/Scrollbar';
Dialog,
DialogContainer,
DialogOverlay,
DialogTitle,
IconButton,
MultiToggle,
MultiToggleItem,
lightMode,
styled,
theme,
} from "../theme";
import Scrollbar from "./utils/Scrollbar";
const Floating = styled('div', {
position: 'fixed',
top: '6px',
right: '10px',
display: 'flex',
gap: '3px',
zIndex: 10,
transition: 'all 100ms',
const Floating = styled("div", {
position: "fixed",
top: "6px",
right: "10px",
display: "flex",
gap: "3px",
zIndex: 10,
transition: "all 100ms",
});
const LogBubble = styled('div', {
borderRadius: '6px',
minWidth: '10px',
minHeight: '10px',
backgroundColor: '$gray6',
color: '$gray11',
padding: '4px 5px 3px',
lineHeight: '0.7rem',
fontSize: '0.7rem',
cursor: 'pointer',
opacity: '0.5',
'&:hover': {
opacity: '1',
},
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: '$yellow6',
color: '$yellow11',
},
ERROR: {
backgroundColor: '$red6',
color: '$red11',
},
},
},
const LogBubble = styled("div", {
borderRadius: "6px",
minWidth: "10px",
minHeight: "10px",
backgroundColor: "$gray6",
color: "$gray11",
padding: "4px 5px 3px",
lineHeight: "0.7rem",
fontSize: "0.7rem",
cursor: "pointer",
opacity: "0.5",
"&:hover": {
opacity: "1",
},
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: "$yellow6",
color: "$yellow11",
},
ERROR: {
backgroundColor: "$red6",
color: "$red11",
},
},
},
});
const emptyFilter = {
INFO: false,
WARN: false,
ERROR: false,
INFO: false,
WARN: false,
ERROR: false,
};
type LogLevel = keyof typeof emptyFilter;
const levels: LogLevel[] = ['INFO', 'WARN', 'ERROR'];
const levels: LogLevel[] = ["INFO", "WARN", "ERROR"];
function isSupportedLevel(level: string): level is LogLevel {
return (levels as string[]).includes(level);
return (levels as string[]).includes(level);
}
function formatTime(time: Date): string {
return [time.getHours(), time.getMinutes(), time.getSeconds()]
.map((x) => x.toString().padStart(2, '0'))
.join(':');
return [time.getHours(), time.getMinutes(), time.getSeconds()]
.map((x) => x.toString().padStart(2, "0"))
.join(":");
}
const LevelToggle = styled(MultiToggleItem, {
[`.${lightMode} &`]: {
border: '2px solid $gray4',
borderLeftWidth: '1px',
borderRightWidth: '1px',
},
color: '$gray8',
"&[data-state='on']": {
color: '$gray12',
},
variants: {
level: {
INFO: {
backgroundColor: '$gray4',
[`.${lightMode} &`]: {
backgroundColor: '$gray2',
},
borderColor: '$gray6',
'&:not(:disabled)': {
'&:hover': {
backgroundColor: '$gray5',
borderColor: '$gray6',
[`.${lightMode} &`]: {
backgroundColor: '$gray2',
},
},
"&[data-state='on']": {
backgroundColor: '$gray8',
borderColor: '$gray6',
[`.${lightMode} &`]: {
backgroundColor: '$gray4',
},
},
},
},
WARN: {
backgroundColor: '$yellow4',
[`.${lightMode} &`]: {
backgroundColor: '$yellow2',
},
borderColor: '$yellow6',
'&:not(:disabled)': {
'&:hover': {
backgroundColor: '$yellow5',
borderColor: '$yellow5',
[`.${lightMode} &`]: {
backgroundColor: '$yellow2',
},
},
"&[data-state='on']": {
backgroundColor: '$yellow8',
borderColor: '$yellow6',
[`.${lightMode} &`]: {
backgroundColor: '$yellow4',
},
},
},
},
ERROR: {
backgroundColor: '$red4',
[`.${lightMode} &`]: {
backgroundColor: '$red2',
},
borderColor: '$red6',
'&:not(:disabled)': {
'&:hover': {
backgroundColor: '$red5',
borderColor: '$red5',
[`.${lightMode} &`]: {
backgroundColor: '$red2',
},
},
"&[data-state='on']": {
backgroundColor: '$red8',
borderColor: '$red6',
[`.${lightMode} &`]: {
backgroundColor: '$red4',
},
},
},
},
},
},
[`.${lightMode} &`]: {
border: "2px solid $gray4",
borderLeftWidth: "1px",
borderRightWidth: "1px",
},
color: "$gray8",
"&[data-state='on']": {
color: "$gray12",
},
variants: {
level: {
INFO: {
backgroundColor: "$gray4",
[`.${lightMode} &`]: {
backgroundColor: "$gray2",
},
borderColor: "$gray6",
"&:not(:disabled)": {
"&:hover": {
backgroundColor: "$gray5",
borderColor: "$gray6",
[`.${lightMode} &`]: {
backgroundColor: "$gray2",
},
},
"&[data-state='on']": {
backgroundColor: "$gray8",
borderColor: "$gray6",
[`.${lightMode} &`]: {
backgroundColor: "$gray4",
},
},
},
},
WARN: {
backgroundColor: "$yellow4",
[`.${lightMode} &`]: {
backgroundColor: "$yellow2",
},
borderColor: "$yellow6",
"&:not(:disabled)": {
"&:hover": {
backgroundColor: "$yellow5",
borderColor: "$yellow5",
[`.${lightMode} &`]: {
backgroundColor: "$yellow2",
},
},
"&[data-state='on']": {
backgroundColor: "$yellow8",
borderColor: "$yellow6",
[`.${lightMode} &`]: {
backgroundColor: "$yellow4",
},
},
},
},
ERROR: {
backgroundColor: "$red4",
[`.${lightMode} &`]: {
backgroundColor: "$red2",
},
borderColor: "$red6",
"&:not(:disabled)": {
"&:hover": {
backgroundColor: "$red5",
borderColor: "$red5",
[`.${lightMode} &`]: {
backgroundColor: "$red2",
},
},
"&[data-state='on']": {
backgroundColor: "$red8",
borderColor: "$red6",
[`.${lightMode} &`]: {
backgroundColor: "$red4",
},
},
},
},
},
},
});
interface LogItemProps {
data: ProcessedLogEntry;
expandDefault?: boolean;
data: ProcessedLogEntry;
expandDefault?: boolean;
}
const LogEntryContainer = styled('div', {
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray4',
display: 'grid',
gridTemplateColumns: '75px 1fr',
fontSize: '0.9em',
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: '$yellow4',
},
ERROR: {
backgroundColor: '$red6',
},
},
},
const LogEntryContainer = styled("div", {
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray4",
display: "grid",
gridTemplateColumns: "75px 1fr",
fontSize: "0.9em",
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: "$yellow4",
},
ERROR: {
backgroundColor: "$red6",
},
},
},
});
const LogTime = styled('div', {
backgroundColor: '$gray6',
gridColumn: '1',
gridRow: '1/3',
padding: '0.2rem 0.5rem',
textAlign: 'center',
color: '$gray11',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderTopLeftRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
INFO: {},
WARN: {
color: '$yellow11',
backgroundColor: '$yellow6',
},
ERROR: {
color: '$red11',
backgroundColor: '$red7',
},
},
},
const LogTime = styled("div", {
backgroundColor: "$gray6",
gridColumn: "1",
gridRow: "1/3",
padding: "0.2rem 0.5rem",
textAlign: "center",
color: "$gray11",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderTopLeftRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
INFO: {},
WARN: {
color: "$yellow11",
backgroundColor: "$yellow6",
},
ERROR: {
color: "$red11",
backgroundColor: "$red7",
},
},
},
});
const LogMessage = styled('div', {
gridColumn: '2',
padding: '0.4rem 0.5rem',
wordBreak: 'break-all',
const LogMessage = styled("div", {
gridColumn: "2",
padding: "0.4rem 0.5rem",
wordBreak: "break-all",
});
const LogActions = styled('div', {
gridColumn: '3',
display: 'flex',
gap: '10px',
padding: '0.4rem 12px 0',
'& a': {
color: '$gray10',
'&:hover': {
color: '$gray12',
cursor: 'pointer',
},
},
variants: {
level: {
INFO: {},
WARN: {
'& a:hover': {
color: '$yellow11',
},
},
ERROR: {
'& a:hover': {
color: '$red11',
},
},
},
},
const LogActions = styled("div", {
gridColumn: "3",
display: "flex",
gap: "10px",
padding: "0.4rem 12px 0",
"& a": {
color: "$gray10",
"&:hover": {
color: "$gray12",
cursor: "pointer",
},
},
variants: {
level: {
INFO: {},
WARN: {
"& a:hover": {
color: "$yellow11",
},
},
ERROR: {
"& a:hover": {
color: "$red11",
},
},
},
},
});
const LogDetails = styled('div', {
gridRow: '2',
gridColumn: '2/4',
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem 1rem',
fontSize: '0.8em',
color: '$gray11',
backgroundColor: '$gray3',
padding: '0.5rem 0.5rem 0.3rem',
borderBottomRightRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: '$yellow3',
},
ERROR: {
backgroundColor: '$red4',
},
},
},
const LogDetails = styled("div", {
gridRow: "2",
gridColumn: "2/4",
display: "flex",
flexWrap: "wrap",
gap: "0.5rem 1rem",
fontSize: "0.8em",
color: "$gray11",
backgroundColor: "$gray3",
padding: "0.5rem 0.5rem 0.3rem",
borderBottomRightRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
variants: {
level: {
INFO: {},
WARN: {
backgroundColor: "$yellow3",
},
ERROR: {
backgroundColor: "$red4",
},
},
},
});
const LogDetailItem = styled('div', {
display: 'flex',
gap: '0.5rem',
const LogDetailItem = styled("div", {
display: "flex",
gap: "0.5rem",
});
const LogDetailKey = styled('div', {
color: '$teal10',
variants: {
level: {
INFO: {},
WARN: {
color: '$yellow11',
},
ERROR: {
color: '$red11',
},
},
},
const LogDetailKey = styled("div", {
color: "$teal10",
variants: {
level: {
INFO: {},
WARN: {
color: "$yellow11",
},
ERROR: {
color: "$red11",
},
},
},
});
const LogDetailValue = styled('div', { flex: '1' });
const LogDetailValue = styled("div", { flex: "1" });
export function LogItem({ data, expandDefault }: LogItemProps) {
const { t } = useTranslation();
const levelStyle = isSupportedLevel(data.level) ? data.level : null;
const details = Object.entries(data.data).filter(([key]) => key.length > 1);
const [copied, setCopied] = useState(false);
const [showDetails, setShowDetails] = useState(expandDefault ?? false);
const { t } = useTranslation();
const levelStyle = isSupportedLevel(data.level) ? data.level : null;
const details = Object.entries(data.data).filter(([key]) => key.length > 1);
const [copied, setCopied] = useState(false);
const [showDetails, setShowDetails] = useState(expandDefault ?? false);
const copyToClipboard = async () => {
await navigator.clipboard.writeText(JSON.stringify(data.data));
setCopied(true);
await delay(2000);
setCopied(false);
};
const copyToClipboard = async () => {
await navigator.clipboard.writeText(JSON.stringify(data.data));
setCopied(true);
await delay(2000);
setCopied(false);
};
return (
<LogEntryContainer level={levelStyle}>
<LogTime level={levelStyle}>{formatTime(data.time)}</LogTime>
<LogMessage>{data.message}</LogMessage>
<LogActions level={levelStyle}>
{details.length > 0 ? (
<a
aria-label={t('logging.toggle-details')}
title={t('logging.toggle-details')}
onClick={() => {
setShowDetails(!showDetails);
}}
>
<SizeIcon />
</a>
) : null}
{copied ? (
<span style={{ fontSize: '0.9em' }}>{t('logging.copied')}</span>
) : (
<a
aria-label={t('logging.copy-to-clipboard')}
title={t('logging.copy-to-clipboard')}
onClick={() => {
void copyToClipboard();
}}
>
<ClipboardCopyIcon />
</a>
)}
</LogActions>
{details.length > 0 && showDetails ? (
<LogDetails level={levelStyle}>
{details.map(([key, value]) => (
<LogDetailItem key={key}>
<LogDetailKey level={levelStyle}>{key}</LogDetailKey>
<LogDetailValue>{JSON.stringify(value)}</LogDetailValue>
</LogDetailItem>
))}
</LogDetails>
) : null}
</LogEntryContainer>
);
return (
<LogEntryContainer level={levelStyle}>
<LogTime level={levelStyle}>{formatTime(data.time)}</LogTime>
<LogMessage>{data.message}</LogMessage>
<LogActions level={levelStyle}>
{details.length > 0 ? (
<a
aria-label={t("logging.toggle-details")}
title={t("logging.toggle-details")}
onClick={() => {
setShowDetails(!showDetails);
}}
>
<SizeIcon />
</a>
) : null}
{copied ? (
<span style={{ fontSize: "0.9em" }}>{t("logging.copied")}</span>
) : (
<a
aria-label={t("logging.copy-to-clipboard")}
title={t("logging.copy-to-clipboard")}
onClick={() => {
void copyToClipboard();
}}
>
<ClipboardCopyIcon />
</a>
)}
</LogActions>
{details.length > 0 && showDetails ? (
<LogDetails level={levelStyle}>
{details.map(([key, value]) => (
<LogDetailItem key={key}>
<LogDetailKey level={levelStyle}>{key}</LogDetailKey>
<LogDetailValue>{JSON.stringify(value)}</LogDetailValue>
</LogDetailItem>
))}
</LogDetails>
) : null}
</LogEntryContainer>
);
}
const LogEntriesContainer = styled('div', {
display: 'flex',
flexDirection: 'column',
gap: '3px',
const LogEntriesContainer = styled("div", {
display: "flex",
flexDirection: "column",
gap: "3px",
});
interface LogDialogProps {
initialFilter: LogLevel[];
initialFilter: LogLevel[];
}
function LogDialog({ initialFilter }: LogDialogProps) {
const logEntries = useAppSelector((state) => state.logging.messages);
const [filter, setFilter] = useState({
...emptyFilter,
...Object.fromEntries(initialFilter.map((f) => [f, true])),
});
const { t } = useTranslation();
const enabled = levels.filter((level) => filter[level]);
const logEntries = useAppSelector((state) => state.logging.messages);
const [filter, setFilter] = useState({
...emptyFilter,
...Object.fromEntries(initialFilter.map((f) => [f, true])),
});
const { t } = useTranslation();
const enabled = levels.filter((level) => filter[level]);
const count = logEntries.reduce(
(acc, entry) => {
if (entry.level in acc) {
acc[entry.level] += 1;
} else {
acc[entry.level] = 1;
}
return acc;
},
{} as Record<string, number>,
);
const count = logEntries.reduce(
(acc, entry) => {
if (entry.level in acc) {
acc[entry.level] += 1;
} else {
acc[entry.level] = 1;
}
return acc;
},
{} 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')}
>
<DialogOverlay />
<DialogContainer style={{ padding: '0.5rem' }}>
<DialogTitle
style={{
display: 'flex',
gap: '1rem',
margin: '-0.5rem',
marginBottom: '0.5rem',
}}
>
{t('logging.dialog-title')}
<MultiToggle
type="multiple"
aria-label={t(`logging.levelFilter`)}
value={enabled}
onValueChange={(values: LogLevel[]) => {
const newFilter = { ...emptyFilter };
values.forEach((level) => {
newFilter[level] = true;
});
setFilter(newFilter);
}}
>
{levels.map((level) => (
<LevelToggle
key={level}
size="small"
level={level}
value={level}
aria-label={t(`logging.level.${level}`)}
>
{t(`logging.level.${level}`)} ({count[level] ?? 0})
</LevelToggle>
))}
</MultiToggle>
<DialogPrimitive.DialogClose asChild>
<IconButton>
<Cross2Icon />
</IconButton>
</DialogPrimitive.DialogClose>
</DialogTitle>
<Scrollbar
vertical={true}
viewport={{ maxHeight: 'calc(80vh - 100px)' }}
>
<LogEntriesContainer>
{filtered.reverse().map((entry) => (
<LogItem key={entry.id} data={entry} />
))}
</LogEntriesContainer>
</Scrollbar>
</DialogContainer>
</DialogPrimitive.Portal>
);
return (
<DialogPrimitive.Portal container={document.getElementById("app-container")}>
<DialogOverlay />
<DialogContainer style={{ padding: "0.5rem" }}>
<DialogTitle
style={{
display: "flex",
gap: "1rem",
margin: "-0.5rem",
marginBottom: "0.5rem",
}}
>
{t("logging.dialog-title")}
<MultiToggle
type="multiple"
aria-label={t("logging.levelFilter")}
value={enabled}
onValueChange={(values: LogLevel[]) => {
const newFilter = { ...emptyFilter };
for (const level of values) {
newFilter[level] = true;
}
setFilter(newFilter);
}}
>
{levels.map((level) => (
<LevelToggle
key={level}
size="small"
level={level}
value={level}
aria-label={t(`logging.level.${level}`)}
>
{t(`logging.level.${level}`)} ({count[level] ?? 0})
</LevelToggle>
))}
</MultiToggle>
<DialogPrimitive.DialogClose asChild>
<IconButton>
<Cross2Icon />
</IconButton>
</DialogPrimitive.DialogClose>
</DialogTitle>
<Scrollbar vertical={true} viewport={{ maxHeight: "calc(80vh - 100px)" }}>
<LogEntriesContainer>
{filtered.reverse().map((entry) => (
<LogItem key={entry.id} data={entry} />
))}
</LogEntriesContainer>
</Scrollbar>
</DialogContainer>
</DialogPrimitive.Portal>
);
}
function LogViewer() {
const logEntries = useAppSelector((state) => state.logging.messages);
const [activeDialog, setActiveDialog] = useState<LogLevel>(null);
const logEntries = useAppSelector((state) => state.logging.messages);
const [activeDialog, setActiveDialog] = useState<LogLevel>(null);
const count = logEntries.reduce(
(acc, entry) => {
if (entry.level in acc) {
acc[entry.level] += 1;
} else {
acc[entry.level] = 1;
}
return acc;
},
{} as Record<string, number>,
);
const count = logEntries.reduce(
(acc, entry) => {
if (entry.level in acc) {
acc[entry.level] += 1;
} else {
acc[entry.level] = 1;
}
return acc;
},
{} as Record<string, number>,
);
return (
<div>
<Floating>
{levels.map((level) =>
level in count && count[level] > 0 ? (
<LogBubble
key={level}
level={level}
onClick={() => setActiveDialog(level)}
>
{count[level]}
</LogBubble>
) : null,
)}
</Floating>
return (
<div>
<Floating>
{levels
.filter((level) => level in count && count[level] > 0)
.map((level) => (
<LogBubble key={level} level={level} onClick={() => setActiveDialog(level)}>
{count[level]}
</LogBubble>
))}
</Floating>
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog ? (
<LogDialog
initialFilter={levels.slice(levels.indexOf(activeDialog))}
/>
) : null}
</Dialog>
</div>
);
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog ? (
<LogDialog initialFilter={levels.slice(levels.indexOf(activeDialog))} />
) : null}
</Dialog>
</div>
);
}
const PureLogViewer = React.memo(LogViewer);

View File

@ -1,159 +1,153 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { styled, Toolbar, ToolbarButton, ToolbarComboBox } from '../theme';
import React from "react";
import { useTranslation } from "react-i18next";
import { styled, Toolbar, ToolbarButton, ToolbarComboBox } from "../theme";
export interface PageListProps {
current: number;
max: number;
min: number;
itemsPerPage: number;
onSelectChange: (itemsPerPage: number) => void;
onPageChange: (page: number) => void;
current: number;
max: number;
min: number;
itemsPerPage: number;
onSelectChange: (itemsPerPage: number) => void;
onPageChange: (page: number) => void;
}
const ToolbarSection = styled('section', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem',
const ToolbarSection = styled("section", {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.3rem",
});
function PageList({
current,
max,
min,
itemsPerPage,
onSelectChange,
onPageChange,
current,
max,
min,
itemsPerPage,
onSelectChange,
onPageChange,
}: PageListProps): React.ReactElement {
const { t } = useTranslation();
return (
<Toolbar
role="navigation"
aria-label={t('pagination.title')}
css={{
'@medium': {
flexDirection: 'row-reverse',
justifyContent: 'space-between',
},
}}
>
<ToolbarSection css={{ flex: 1, '@medium': { flex: 0 } }}>
<ToolbarButton
aria-label={t('pagination.previous')}
title={t('pagination.previous')}
disabled={current <= min}
onClick={() => onPageChange(current - 1)}
css={{ flex: 1, '@medium': { flex: 0 } }}
>
&lsaquo;
</ToolbarButton>
<ToolbarButton
aria-label={t('pagination.next')}
title={t('pagination.next')}
disabled={current >= max}
onClick={() => onPageChange(current + 1)}
css={{ flex: 1, '@medium': { flex: 0 } }}
>
&rsaquo;
</ToolbarButton>
<ToolbarComboBox
title={t('pagination.items-per-page')}
aria-label={t('pagination.items-per-page')}
value={itemsPerPage}
onChange={(ev) => onSelectChange(Number(ev.target.value))}
css={{
textAlign: 'center',
flex: 1,
'@medium': { flex: 0 },
}}
>
<option value={15}>15</option>
<option value={30}>30</option>
<option value={50}>50</option>
<option value={100}>100</option>
</ToolbarComboBox>
</ToolbarSection>
<ToolbarSection>
<div style={{ padding: '0 0.25rem' }}>
{t('pagination.page', { page: current })}
</div>
{current > min ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotofirst')}
title={t('pagination.gotofirst')}
onClick={() => onPageChange(min)}
>
{min}
</ToolbarButton>
) : null}
{current > min + 2 ? (
<span className="pagination-ellipsis">&hellip;</span>
) : null}
const { t } = useTranslation();
return (
<Toolbar
role="navigation"
aria-label={t("pagination.title")}
css={{
"@medium": {
flexDirection: "row-reverse",
justifyContent: "space-between",
},
}}
>
<ToolbarSection css={{ flex: 1, "@medium": { flex: 0 } }}>
<ToolbarButton
aria-label={t("pagination.previous")}
title={t("pagination.previous")}
disabled={current <= min}
onClick={() => onPageChange(current - 1)}
css={{ flex: 1, "@medium": { flex: 0 } }}
>
&lsaquo;
</ToolbarButton>
<ToolbarButton
aria-label={t("pagination.next")}
title={t("pagination.next")}
disabled={current >= max}
onClick={() => onPageChange(current + 1)}
css={{ flex: 1, "@medium": { flex: 0 } }}
>
&rsaquo;
</ToolbarButton>
<ToolbarComboBox
title={t("pagination.items-per-page")}
aria-label={t("pagination.items-per-page")}
value={itemsPerPage}
onChange={(ev) => onSelectChange(Number(ev.target.value))}
css={{
textAlign: "center",
flex: 1,
"@medium": { flex: 0 },
}}
>
<option value={15}>15</option>
<option value={30}>30</option>
<option value={50}>50</option>
<option value={100}>100</option>
</ToolbarComboBox>
</ToolbarSection>
<ToolbarSection>
<div style={{ padding: "0 0.25rem" }}>{t("pagination.page", { page: current })}</div>
{current > min ? (
<ToolbarButton
className="button pagination-link"
aria-label={t("pagination.gotofirst")}
title={t("pagination.gotofirst")}
onClick={() => onPageChange(min)}
>
{min}
</ToolbarButton>
) : null}
{current > min + 2 ? <span className="pagination-ellipsis">&hellip;</span> : null}
{current > min + 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotopage', {
page: current - 1,
})}
title={t('pagination.gotopage', {
page: current - 1,
})}
onClick={() => onPageChange(current - 1)}
>
{current - 1}
</ToolbarButton>
) : null}
<ToolbarButton
disabled={true}
className="pagination-link is-current"
aria-label={t('pagination.page', {
page: current,
})}
title={t('pagination.page', {
page: current,
})}
aria-current="page"
css={{
border: '1px solid $teal7',
cursor: 'inherit',
background: '$teal7',
}}
>
{current}
</ToolbarButton>
{current < max ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotopage', {
page: current + 1,
})}
title={t('pagination.gotopage', {
page: current + 1,
})}
onClick={() => onPageChange(current + 1)}
>
{current + 1}
</ToolbarButton>
) : null}
{current < max - 2 ? (
<span className="pagination-ellipsis">&hellip;</span>
) : null}
{current < max - 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotolast')}
title={t('pagination.gotolast')}
onClick={() => onPageChange(max)}
>
{max}
</ToolbarButton>
) : null}
</ToolbarSection>
</Toolbar>
);
{current > min + 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t("pagination.gotopage", {
page: current - 1,
})}
title={t("pagination.gotopage", {
page: current - 1,
})}
onClick={() => onPageChange(current - 1)}
>
{current - 1}
</ToolbarButton>
) : null}
<ToolbarButton
disabled={true}
className="pagination-link is-current"
aria-label={t("pagination.page", {
page: current,
})}
title={t("pagination.page", {
page: current,
})}
aria-current="page"
css={{
border: "1px solid $teal7",
cursor: "inherit",
background: "$teal7",
}}
>
{current}
</ToolbarButton>
{current < max ? (
<ToolbarButton
className="button pagination-link"
aria-label={t("pagination.gotopage", {
page: current + 1,
})}
title={t("pagination.gotopage", {
page: current + 1,
})}
onClick={() => onPageChange(current + 1)}
>
{current + 1}
</ToolbarButton>
) : null}
{current < max - 2 ? <span className="pagination-ellipsis">&hellip;</span> : null}
{current < max - 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t("pagination.gotolast")}
title={t("pagination.gotolast")}
onClick={() => onPageChange(max)}
>
{max}
</ToolbarButton>
) : null}
</ToolbarSection>
</Toolbar>
);
}
const PurePageList = React.memo(PageList);

View File

@ -1,343 +1,335 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import type React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useMatch, useResolvedPath } from "react-router-dom";
// @ts-expect-error Asset import
import logo from '~/assets/icon-logo.svg';
import logo from "~/assets/icon-logo.svg";
import { useAppSelector } from '~/store';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import { APPNAME, lightMode, styled } from '../theme';
import BrowserLink from './BrowserLink';
import Scrollbar from './utils/Scrollbar';
import { useAppSelector } from "~/store";
import { ExternalLinkIcon } from "@radix-ui/react-icons";
import { APPNAME, lightMode, styled } from "../theme";
import BrowserLink from "./BrowserLink";
import Scrollbar from "./utils/Scrollbar";
export interface RouteSection {
title: string;
short: string;
links: Route[];
title: string;
short: string;
links: Route[];
}
export interface Route {
title: string;
url: string;
icon?: JSX.Element;
title: string;
url: string;
icon?: JSX.Element;
}
interface SidebarProps {
sections: RouteSection[];
sections: RouteSection[];
}
const Container = styled('section', {
background: '$gray2',
borderRight: '1px solid $gray6',
width: '60px',
transition: 'max-width 0.1s ease',
'@medium': {
width: 'auto',
maxWidth: '220px',
},
const Container = styled("section", {
background: "$gray2",
borderRight: "1px solid $gray6",
width: "60px",
transition: "max-width 0.1s ease",
"@medium": {
width: "auto",
maxWidth: "220px",
},
[`.${lightMode} &`]: {
background: '$gray2',
},
[`.${lightMode} &`]: {
background: "$gray2",
},
});
const Header = styled('div', {
textAlign: 'center',
'@medium': {
padding: '0.8rem 1rem 1rem 0.8rem',
},
const Header = styled("div", {
textAlign: "center",
"@medium": {
padding: "0.8rem 1rem 1rem 0.8rem",
},
});
const AppName = styled('h1', {
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.2rem',
fontSize: '1.4rem',
margin: '0.5rem 0 0.5rem 0',
fontWeight: 300,
const AppName = styled("h1", {
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.2rem",
fontSize: "1.4rem",
margin: "0.5rem 0 0.5rem 0",
fontWeight: 300,
'@medium': {
paddingRight: '0.5rem',
},
"@medium": {
paddingRight: "0.5rem",
},
span: {
display: 'none',
'@medium': {
display: 'initial',
},
},
span: {
display: "none",
"@medium": {
display: "initial",
},
},
});
const AppLink = styled(Link, {
userSelect: 'none',
all: 'unset',
cursor: 'pointer',
color: '$gray12',
'&:visited': {
color: '$gray12',
},
display: 'flex',
flexDirection: 'column',
variants: {
status: {
active: {
backgroundColor: '$teal4',
'@medium': {
borderRadius: '0.5rem',
},
},
default: {},
},
},
userSelect: "none",
all: "unset",
cursor: "pointer",
color: "$gray12",
"&:visited": {
color: "$gray12",
},
display: "flex",
flexDirection: "column",
variants: {
status: {
active: {
backgroundColor: "$teal4",
"@medium": {
borderRadius: "0.5rem",
},
},
default: {},
},
},
});
const VersionLabel = styled('div', {
userSelect: 'none',
textTransform: 'uppercase',
fontSize: '0.75rem',
fontWeight: 'bold',
color: '$teal8',
textAlign: 'center',
paddingBottom: '0.4rem',
display: 'none',
'@medium': {
display: 'initial',
},
const VersionLabel = styled("div", {
userSelect: "none",
textTransform: "uppercase",
fontSize: "0.75rem",
fontWeight: "bold",
color: "$teal8",
textAlign: "center",
paddingBottom: "0.4rem",
display: "none",
"@medium": {
display: "initial",
},
});
const UpdateButton = styled(BrowserLink, {
textTransform: 'uppercase',
fontSize: '0.75rem',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
color: '$yellow12 !important',
border: '1px solid $yellow7',
padding: '0.2rem 0.4rem',
margin: '0.5rem 0.5rem',
backgroundColor: '$yellow5',
borderRadius: '0.2rem',
cursor: 'pointer',
textDecoration: 'none',
'@medium': {
margin: '0.5rem 0 0 0',
},
'&:hover': {
backgroundColor: '$yellow6',
},
justifyContent: 'center',
span: {
flex: 1,
display: 'none',
'@medium': {
display: 'initial',
},
},
textTransform: "uppercase",
fontSize: "0.75rem",
fontWeight: "bold",
display: "flex",
alignItems: "center",
color: "$yellow12 !important",
border: "1px solid $yellow7",
padding: "0.2rem 0.4rem",
margin: "0.5rem 0.5rem",
backgroundColor: "$yellow5",
borderRadius: "0.2rem",
cursor: "pointer",
textDecoration: "none",
"@medium": {
margin: "0.5rem 0 0 0",
},
"&:hover": {
backgroundColor: "$yellow6",
},
justifyContent: "center",
span: {
flex: 1,
display: "none",
"@medium": {
display: "initial",
},
},
});
const MenuSection = styled('article', {
display: 'flex',
flexDirection: 'column',
padding: '0.2rem 0 0.5rem 0',
const MenuSection = styled("article", {
display: "flex",
flexDirection: "column",
padding: "0.2rem 0 0.5rem 0",
});
const MenuHeader = styled('header', {
textTransform: 'uppercase',
fontSize: '0.75rem',
fontWeight: 'bold',
padding: '0.5rem 0 0.5rem',
color: '$teal9',
userSelect: 'none',
textAlign: 'center',
'@medium': {
textAlign: 'left',
padding: '0.5rem 0 0.5rem 0.8rem',
},
const MenuHeader = styled("header", {
textTransform: "uppercase",
fontSize: "0.75rem",
fontWeight: "bold",
padding: "0.5rem 0 0.5rem",
color: "$teal9",
userSelect: "none",
textAlign: "center",
"@medium": {
textAlign: "left",
padding: "0.5rem 0 0.5rem 0.8rem",
},
});
const FullTitle = styled('span', {
display: 'none',
'@medium': {
display: 'initial',
},
const FullTitle = styled("span", {
display: "none",
"@medium": {
display: "initial",
},
});
const ShortTitle = styled('span', {
display: 'initial',
'@medium': {
display: 'none',
},
const ShortTitle = styled("span", {
display: "initial",
"@medium": {
display: "none",
},
});
const MenuLink = styled(Link, {
userSelect: 'none',
color: '$teal13 !important',
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
gap: '0.6rem',
fontSize: '0.9rem',
fontWeight: '300',
justifyContent: 'center',
padding: '0.6rem',
'@medium': {
justifyContent: 'flex-start',
padding: '0.6rem 1.6rem 0.6rem 1rem',
},
[`.${lightMode} &`]: {
fontWeight: '400',
},
variants: {
status: {
selected: {
color: '$teal13 !important',
backgroundColor: '$teal5',
},
clickable: {
cursor: 'pointer',
'&:hover': {
backgroundColor: '$gray3',
},
},
},
},
span: {
display: 'none',
'@medium': {
display: 'initial',
},
},
userSelect: "none",
color: "$teal13 !important",
display: "flex",
alignItems: "center",
textDecoration: "none",
gap: "0.6rem",
fontSize: "0.9rem",
fontWeight: "300",
justifyContent: "center",
padding: "0.6rem",
"@medium": {
justifyContent: "flex-start",
padding: "0.6rem 1.6rem 0.6rem 1rem",
},
[`.${lightMode} &`]: {
fontWeight: "400",
},
variants: {
status: {
selected: {
color: "$teal13 !important",
backgroundColor: "$teal5",
},
clickable: {
cursor: "pointer",
"&:hover": {
backgroundColor: "$gray3",
},
},
},
},
span: {
display: "none",
"@medium": {
display: "initial",
},
},
});
const AppLogo = styled('img', {
height: '28px',
marginBottom: '-2px',
[`.${lightMode} &`]: {
filter: 'invert(1)',
},
const AppLogo = styled("img", {
height: "28px",
marginBottom: "-2px",
[`.${lightMode} &`]: {
filter: "invert(1)",
},
});
function SidebarLink({ route: { title, url, icon } }: { route: Route }) {
const { t } = useTranslation();
const resolved = useResolvedPath(url);
const match = useMatch({ path: resolved.pathname, end: true });
return (
<MenuLink
status={match ? 'selected' : 'clickable'}
to={url.replace(/\/\//gi, '/')}
key={`${title}-${url}`}
title={t(title)}
>
{icon}
<span>{t(title)}</span>
</MenuLink>
);
const { t } = useTranslation();
const resolved = useResolvedPath(url);
const match = useMatch({ path: resolved.pathname, end: true });
return (
<MenuLink
status={match ? "selected" : "clickable"}
to={url.replace(/\/\//gi, "/")}
key={`${title}-${url}`}
title={t(title)}
>
{icon}
<span>{t(title)}</span>
</MenuLink>
);
}
function parseVersion(semanticVersion: string) {
const [version, prerelease] = semanticVersion.split('-', 2);
const [major, minor, patch] = version.split('.').map((x) => parseInt(x, 10));
return { major, minor, patch, prerelease };
const [version, prerelease] = semanticVersion.split("-", 2);
const [major, minor, patch] = version.split(".").map((x) => Number.parseInt(x, 10));
return { major, minor, patch, prerelease };
}
function hasLatestOrBeta(current: string, latest: string): boolean {
// If current version has no prerelease tag, just do a string check
if (!current.includes('-', 6)) {
return current.startsWith(latest);
}
// If current version has no prerelease tag, just do a string check
if (!current.includes("-", 6)) {
return current.startsWith(latest);
}
// Split MAJOR/MINOR/PATCH and check each
const parsedCurrent = parseVersion(current);
const parsedLatest = parseVersion(latest);
// Split MAJOR/MINOR/PATCH and check each
const parsedCurrent = parseVersion(current);
const parsedLatest = parseVersion(latest);
if (
parsedCurrent.major > parsedLatest.major ||
parsedCurrent.minor > parsedLatest.minor ||
parsedCurrent.patch > parsedLatest.patch
) {
return true;
}
if (
parsedCurrent.major > parsedLatest.major ||
parsedCurrent.minor > parsedLatest.minor ||
parsedCurrent.patch > parsedLatest.patch
) {
return true;
}
// If latest has no prerelease, we assume stable
if (!parsedLatest.prerelease) {
return true;
}
// If latest has no prerelease, we assume stable
if (!parsedLatest.prerelease) {
return true;
}
// Sort by prerelease (this breaks with high numbers but hopefully we won't get to alpha.10)
return parsedCurrent.prerelease > parsedLatest.prerelease;
// Sort by prerelease (this breaks with high numbers but hopefully we won't get to alpha.10)
return parsedCurrent.prerelease > parsedLatest.prerelease;
}
interface VersionInfo {
name: string;
url: string;
name: string;
url: string;
}
interface UpdateInfo {
stable: VersionInfo;
latest: VersionInfo;
stable: VersionInfo;
latest: VersionInfo;
}
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 prerelease = !dev && version.includes('-', 6);
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?.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);
}
}
useEffect(() => {
void fetchLastVersion();
}, []);
void fetchLastVersion();
}, [prerelease]);
return (
<Container>
<Scrollbar vertical={true} viewport={{ maxHeight: '100vh' }}>
<Header>
<AppLink to={'/about'} status={matchApp ? 'active' : 'default'}>
<AppName>
<AppLogo src={logo as string} />
<span>{APPNAME}</span>
</AppName>
<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>
)}
</Header>
{sections.map(({ title: sectionTitle, short, links }) => (
<MenuSection key={sectionTitle}>
<MenuHeader>
<FullTitle>{t(sectionTitle)}</FullTitle>
<ShortTitle title={t(sectionTitle)}>{t(short)}</ShortTitle>
</MenuHeader>
{links.map((route) => (
<SidebarLink route={route} key={`${route.title}-${route.url}`} />
))}
</MenuSection>
))}
</Scrollbar>
</Container>
);
return (
<Container>
<Scrollbar vertical={true} viewport={{ maxHeight: "100vh" }}>
<Header>
<AppLink to={"/about"} status={matchApp ? "active" : "default"}>
<AppName>
<AppLogo src={logo as string} />
<span>{APPNAME}</span>
</AppName>
<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>
)}
</Header>
{sections.map(({ title: sectionTitle, short, links }) => (
<MenuSection key={sectionTitle}>
<MenuHeader>
<FullTitle>{t(sectionTitle)}</FullTitle>
<ShortTitle title={t(sectionTitle)}>{t(short)}</ShortTitle>
</MenuHeader>
{links.map((route) => (
<SidebarLink route={route} key={`${route.title}-${route.url}`} />
))}
</MenuSection>
))}
</Scrollbar>
</Container>
);
}

View File

@ -1,80 +1,76 @@
import { GetTwitchLoggedUser } from '@wailsapp/go/main/App';
import { 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 { GetTwitchLoggedUser } from "@wailsapp/go/main/App";
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;
error: string;
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
const TwitchUser = styled("div", {
display: "flex",
gap: "0.8rem",
alignItems: "center",
fontSize: "14pt",
fontWeight: "300",
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
const TwitchPic = styled("img", {
width: "48px",
borderRadius: "50%",
});
const TwitchName = styled('p', { fontWeight: 'bold' });
const TwitchName = styled("p", { fontWeight: "bold" });
interface TwitchUserBlockProps {
authKey: string;
noUserMessage: string;
authKey: string;
noUserMessage: string;
}
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);
export default function TwitchUserBlock({ authKey, noUserMessage }: TwitchUserBlockProps) {
const { t } = useTranslation();
const [user, setUser] = useState<helix.User | SyncError>(null);
const kv = useKilovoltClient();
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser(authKey);
setUser(res);
} catch (e) {
setUser({ ok: false, error: (e as Error).message });
}
};
useEffect(() => {
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser(authKey);
setUser(res);
} catch (e) {
setUser({ ok: false, error: (e as Error).message });
}
};
useEffect(() => {
// Get user info
void getUserInfo();
// Get user info
void getUserInfo();
const onKeyChange = () => {
void getUserInfo();
};
void kv.subscribeKey(authKey, onKeyChange);
return () => {
void kv.unsubscribeKey(authKey, onKeyChange);
};
}, []);
const onKeyChange = () => {
void getUserInfo();
};
void kv.subscribeKey(authKey, onKeyChange);
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>
<TwitchPic
src={user.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{user.display_name}</TwitchName>
</TwitchUser>
);
}
return <span>{noUserMessage}</span>;
}
if (user !== null) {
if ("id" in user) {
return (
<TwitchUser>
<TextBlock>{t("pages.twitch-settings.events.authenticated-as")}</TextBlock>
<TwitchPic
src={user.profile_image_url}
alt={t("pages.twitch-settings.events.profile-picture")}
/>
<TwitchName>{user.display_name}</TwitchName>
</TwitchUser>
);
}
return <span>{noUserMessage}</span>;
}
return <i>{t('pages.twitch-settings.events.loading-data')}</i>;
return <i>{t("pages.twitch-settings.events.loading-data")}</i>;
}

View File

@ -2,38 +2,36 @@
// 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);
const ref = useRef<HTMLInputElement>(null);
const { value, onChange, ...rest } = props;
const [cursor, setCursor] = useState<number>(null);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const input = ref.current;
if (input) {
input.setSelectionRange(cursor, cursor);
}
}, [ref, cursor, value]);
useEffect(() => {
const input = ref.current;
if (input) {
input.setSelectionRange(cursor, cursor);
}
}, [cursor]);
return (
<input
ref={ref}
value={value}
onChange={(e) => {
setCursor(e.target.selectionStart);
if (onChange) {
onChange(e);
}
}}
{...rest}
/>
);
return (
<input
ref={ref}
value={value}
onChange={(e) => {
setCursor(e.target.selectionStart);
if (onChange) {
onChange(e);
}
}}
{...rest}
/>
);
};
export default ControlledInput;

View File

@ -1,85 +1,85 @@
import { useTranslation } from 'react-i18next';
import { getInterval } from '~/lib/time';
import { ComboBox, FlexRow, InputBox } from '../../theme';
import { seconds, minutes, hours } from './units';
import { useTranslation } from "react-i18next";
import { getInterval } from "~/lib/time";
import { ComboBox, FlexRow, InputBox } from "../../theme";
import { seconds, minutes, hours } from "./units";
export interface TimeUnit {
multiplier: number;
unit: string;
multiplier: number;
unit: string;
}
export interface IntervalProps {
active: boolean;
value: number;
id?: string;
min?: number;
units?: TimeUnit[];
required?: boolean;
onChange?: (value: number) => void;
active: boolean;
value: number;
id?: string;
min?: number;
units?: TimeUnit[];
required?: boolean;
onChange?: (value: number) => void;
}
export default function Interval({
id,
active,
value,
min,
units,
onChange,
required,
id,
active,
value,
min,
units,
onChange,
required,
}: IntervalProps) {
const { t } = useTranslation();
const { t } = useTranslation();
const timeUnits = units ?? [seconds, minutes, hours];
const timeUnits = units ?? [seconds, minutes, hours];
const [num, mult] = getInterval(value);
const [num, mult] = getInterval(value);
const change = (newNum: number, newMult: number) => {
onChange(Math.max(min ?? 0, newNum * newMult));
};
const change = (newNum: number, newMult: number) => {
onChange(Math.max(min ?? 0, newNum * newMult));
};
return (
<>
<FlexRow align="left" border="form">
<InputBox
id={id}
type="number"
border="none"
required={required}
disabled={!active}
css={{
maxWidth: '5rem',
borderRightWidth: '1px',
borderRadius: '$borderRadius$form 0 0 $borderRadius$form',
}}
value={num ?? ''}
onChange={(ev) => {
const parsedNum = parseInt(ev.target.value, 10);
if (Number.isNaN(parsedNum)) {
return;
}
change(parsedNum, mult);
}}
placeholder="#"
/>
<ComboBox
border="none"
value={mult.toString() ?? ''}
disabled={!active}
onChange={(ev) => {
const parsedMult = parseInt(ev.target.value, 10);
if (Number.isNaN(parsedMult)) {
return;
}
change(num, parsedMult);
}}
>
{timeUnits.map((unit) => (
<option key={unit.unit} value={unit.multiplier.toString()}>
{t(unit.unit)}
</option>
))}
</ComboBox>
</FlexRow>
</>
);
return (
<>
<FlexRow align="left" border="form">
<InputBox
id={id}
type="number"
border="none"
required={required}
disabled={!active}
css={{
maxWidth: "5rem",
borderRightWidth: "1px",
borderRadius: "$borderRadius$form 0 0 $borderRadius$form",
}}
value={num ?? ""}
onChange={(ev) => {
const parsedNum = Number.parseInt(ev.target.value, 10);
if (Number.isNaN(parsedNum)) {
return;
}
change(parsedNum, mult);
}}
placeholder="#"
/>
<ComboBox
border="none"
value={mult.toString() ?? ""}
disabled={!active}
onChange={(ev) => {
const parsedMult = Number.parseInt(ev.target.value, 10);
if (Number.isNaN(parsedMult)) {
return;
}
change(num, parsedMult);
}}
>
{timeUnits.map((unit) => (
<option key={unit.unit} value={unit.multiplier.toString()}>
{t(unit.unit)}
</option>
))}
</ComboBox>
</FlexRow>
</>
);
}

View File

@ -1,94 +1,85 @@
import { Cross2Icon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, FlexRow, Textarea } from '../../theme';
import { Cross2Icon } from "@radix-ui/react-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { Button, FlexRow, Textarea } from "../../theme";
export interface MultiInputProps {
placeholder?: string;
value: string[];
required?: boolean;
disabled?: boolean;
onChange: (value: string[]) => void;
placeholder?: string;
value: string[];
required?: boolean;
disabled?: boolean;
onChange: (value: string[]) => void;
}
function MultiInput({
value,
placeholder,
onChange,
required,
disabled,
}: MultiInputProps) {
const { t } = useTranslation();
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 border="form" css={{ flex: 1, alignItems: 'stretch' }}>
<Textarea
disabled={disabled}
border="none"
required={required}
placeholder={placeholder}
onChange={(ev) => {
const newMessages = [...value];
newMessages[index] = ev.target.value;
onChange(newMessages);
}}
value={message}
className={message !== '' ? 'input' : 'input is-danger'}
css={
value.length > 1
? {
borderRadius: '$borderRadius$form 0 0 $borderRadius$form',
flex: 1,
}
: { flex: 1 }
}
>
{message}
</Textarea>
{value.length > 1 && (
<Button
disabled={disabled}
type="button"
variation="danger"
styling="form"
onClick={() => {
const newMessages = [...value];
newMessages.splice(index, 1);
onChange(newMessages.length > 0 ? newMessages : ['']);
}}
css={{
margin: '-1px',
borderRadius: '0 $borderRadius$form $borderRadius$form 0',
}}
>
<Cross2Icon />
</Button>
)}
</FlexRow>
</FlexRow>
))}
<FlexRow align="left">
<Button
disabled={disabled}
size="small"
styling="link"
type="button"
css={{ marginTop: '0.5rem' }}
onClick={() => {
onChange([...value, '']);
}}
>
{t('form-actions.add')}
</Button>
</FlexRow>
</>
);
return (
<>
{value.map((message, index) => (
<FlexRow key={`${value.length}-${index}`} css={{ marginTop: "0.5rem", flex: 1 }}>
<FlexRow border="form" css={{ flex: 1, alignItems: "stretch" }}>
<Textarea
disabled={disabled}
border="none"
required={required}
placeholder={placeholder}
onChange={(ev) => {
const newMessages = [...value];
newMessages[index] = ev.target.value;
onChange(newMessages);
}}
value={message}
className={message !== "" ? "input" : "input is-danger"}
css={
value.length > 1
? {
borderRadius: "$borderRadius$form 0 0 $borderRadius$form",
flex: 1,
}
: { flex: 1 }
}
>
{message}
</Textarea>
{value.length > 1 && (
<Button
disabled={disabled}
type="button"
variation="danger"
styling="form"
onClick={() => {
const newMessages = [...value];
newMessages.splice(index, 1);
onChange(newMessages.length > 0 ? newMessages : [""]);
}}
css={{
margin: "-1px",
borderRadius: "0 $borderRadius$form $borderRadius$form 0",
}}
>
<Cross2Icon />
</Button>
)}
</FlexRow>
</FlexRow>
))}
<FlexRow align="left">
<Button
disabled={disabled}
size="small"
styling="link"
type="button"
css={{ marginTop: "0.5rem" }}
onClick={() => {
onChange([...value, ""]);
}}
>
{t("form-actions.add")}
</Button>
</FlexRow>
</>
);
}
const PureMultiInput = React.memo(MultiInput);

View File

@ -1,25 +1,16 @@
import React from 'react';
import React from "react";
export interface PasswordFieldProps {
reveal: boolean;
reveal: boolean;
}
function PasswordField(
props: PasswordFieldProps &
React.PropsWithChildren<
React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>
>,
props: PasswordFieldProps &
React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
) {
const subprops = { ...props };
delete subprops.reveal;
return (
<input type={props.reveal ? 'text' : 'password'} {...subprops}>
{props.children}
</input>
);
const subprops = { ...props };
subprops.reveal = undefined;
return <input type={props.reveal ? "text" : "password"} {...subprops} />;
}
const PurePasswordField = React.memo(PasswordField);

View File

@ -1,87 +1,87 @@
import React, { ReactElement } from 'react';
import React, { type ReactElement } from "react";
import {
Root,
Item,
Indicator,
RadioGroupProps as RootProps,
} from '@radix-ui/react-radio-group';
import { lightMode, styled } from '~/ui/theme';
Root,
Item,
Indicator,
type RadioGroupProps as RootProps,
} from "@radix-ui/react-radio-group";
import { lightMode, styled } from "~/ui/theme";
export interface RadioGroupProps {
values: {
id: string;
label: string | ReactElement;
}[];
values: {
id: string;
label: string | ReactElement;
}[];
}
const RadioRoot = styled(Root, {
display: 'flex',
flexDirection: 'column',
gap: '10px',
margin: '0.5rem 0',
'& label': {
cursor: 'pointer',
},
display: "flex",
flexDirection: "column",
gap: "10px",
margin: "0.5rem 0",
"& label": {
cursor: "pointer",
},
});
const RadioItem = styled(Item, {
backgroundColor: '$gray12',
borderRadius: '100%',
width: '22px',
height: '22px',
cursor: 'pointer',
padding: 0,
margin: 0,
border: '0',
marginRight: '0.5rem',
backgroundColor: "$gray12",
borderRadius: "100%",
width: "22px",
height: "22px",
cursor: "pointer",
padding: 0,
margin: 0,
border: "0",
marginRight: "0.5rem",
'&:hover': {
backgroundColor: '$teal12',
"&:hover": {
backgroundColor: "$teal12",
[`.${lightMode} &`]: {
backgroundColor: '$teal3',
},
},
'&:focus': {
boxShadow: '0 0 0 2px $gray2',
},
[`.${lightMode} &`]: {
backgroundColor: "$teal3",
},
},
"&:focus": {
boxShadow: "0 0 0 2px $gray2",
},
[`.${lightMode} &`]: {
backgroundColor: '$gray2',
border: '2px solid $gray12',
},
[`.${lightMode} &`]: {
backgroundColor: "$gray2",
border: "2px solid $gray12",
},
});
const RadioIndicator = styled(Indicator, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
position: 'relative',
'&::after': {
content: '',
display: 'block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: '$teal9',
},
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
position: "relative",
"&::after": {
content: "",
display: "block",
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: "$teal9",
},
});
function RadioGroup(props: RadioGroupProps & RootProps) {
return (
<RadioRoot {...props}>
{props.values.map(({ id, label }) => (
<div key={id} style={{ display: 'flex', alignItems: 'center' }}>
<RadioItem value={id} id={`r${id}`}>
<RadioIndicator />
</RadioItem>
<label htmlFor={`r${id}`}>{label}</label>
</div>
))}
</RadioRoot>
);
return (
<RadioRoot {...props}>
{props.values.map(({ id, label }) => (
<div key={id} style={{ display: "flex", alignItems: "center" }}>
<RadioItem value={id} id={`r${id}`}>
<RadioIndicator />
</RadioItem>
<label htmlFor={`r${id}`}>{label}</label>
</div>
))}
</RadioRoot>
);
}
const PureRadioGroup = React.memo(RadioGroup);

View File

@ -1,34 +1,32 @@
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RequestStatus } from '~/store/api/types';
import { Button } from '../../theme';
import { CheckIcon } from "@radix-ui/react-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import type { RequestStatus } from "~/store/api/types";
import { Button } from "../../theme";
interface SaveButtonProps {
status: RequestStatus;
status: RequestStatus;
}
function SaveButton(
props: SaveButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { t } = useTranslation();
function SaveButton(props: SaveButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { t } = useTranslation();
switch (props.status?.type) {
case 'success':
return (
<Button variation="success" {...props}>
{t('form-actions.saved')} <CheckIcon />
</Button>
);
case 'error':
return (
<Button variation="error" {...props}>
{t('form-actions.error')}
</Button>
);
default:
return <Button variation="primary">{t('form-actions.save')}</Button>;
}
switch (props.status?.type) {
case "success":
return (
<Button variation="success" {...props}>
{t("form-actions.saved")} <CheckIcon />
</Button>
);
case "error":
return (
<Button variation="error" {...props}>
{t("form-actions.error")}
</Button>
);
default:
return <Button variation="primary">{t("form-actions.save")}</Button>;
}
}
const PureSaveButton = React.memo(SaveButton);

View File

@ -1,3 +1,3 @@
export const seconds = { multiplier: 1, unit: 'time.seconds' };
export const minutes = { multiplier: 60, unit: 'time.minutes' };
export const hours = { multiplier: 3600, unit: 'time.hours' };
export const seconds = { multiplier: 1, unit: "time.seconds" };
export const minutes = { multiplier: 60, unit: "time.minutes" };
export const hours = { multiplier: 3600, unit: "time.hours" };

View File

@ -1,35 +1,27 @@
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>
<Channel>
<ChannelLink href="https://lists.sr.ht/~ashkeel/strimertul-devel">
<ChatBubbleIcon width={24} height={24} />
lists.sr.ht/~ashkeel/strimertul-devel
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="https://nebula.cafe/discord">
<DiscordLogoIcon width={24} height={24} />
nebula.cafe/discord
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="mailto:strimertul@nebula.cafe">
<EnvelopeClosedIcon width={24} height={24} />
strimertul@nebula.cafe
</ChannelLink>
</Channel>
</ChannelList>
<ChannelList>
<Channel>
<ChannelLink href="https://lists.sr.ht/~ashkeel/strimertul-devel">
<ChatBubbleIcon width={24} height={24} />
lists.sr.ht/~ashkeel/strimertul-devel
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="https://nebula.cafe/discord">
<DiscordLogoIcon width={24} height={24} />
nebula.cafe/discord
</ChannelLink>
</Channel>
<Channel>
<ChannelLink href="mailto:strimertul@nebula.cafe">
<EnvelopeClosedIcon width={24} height={24} />
strimertul@nebula.cafe
</ChannelLink>
</Channel>
</ChannelList>
);
export default Channels;

View File

@ -1,31 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../theme';
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "../../theme";
export interface RevealLinkProps {
value: boolean;
setter: (newValue: boolean) => void;
value: boolean;
setter: (newValue: boolean) => void;
}
function RevealLink({ value, setter }: RevealLinkProps) {
const { t } = useTranslation();
const text = value
? t('form-actions.password-hide')
: t('form-actions.password-reveal');
return (
<Button
type="button"
css={{ display: 'inline-flex', marginLeft: '0.5rem' }}
onClick={() => {
if (setter) {
setter(!value);
}
}}
size={'smaller'}
>
{text}
</Button>
);
const { t } = useTranslation();
const text = value ? t("form-actions.password-hide") : t("form-actions.password-reveal");
return (
<Button
type="button"
css={{ display: "inline-flex", marginLeft: "0.5rem" }}
onClick={() => {
if (setter) {
setter(!value);
}
}}
size={"smaller"}
>
{text}
</Button>
);
}
const PureRevealLink = React.memo(RevealLink);

View File

@ -1,64 +1,62 @@
import React from 'react';
import * as ScrollArea from '@radix-ui/react-scroll-area';
import { styled } from '../../theme';
import React from "react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { styled } from "../../theme";
export interface ScrollbarProps {
vertical?: boolean;
horizontal?: boolean;
root?: React.CSSProperties;
viewport?: React.CSSProperties;
vertical?: boolean;
horizontal?: boolean;
root?: React.CSSProperties;
viewport?: React.CSSProperties;
}
const StyledScrollbar = styled(ScrollArea.Scrollbar, {
display: 'flex',
userSelect: 'none',
touchAction: 'none',
padding: '2px',
background: '$blackA6',
transition: 'background 160ms ease-out',
'&:hover': {
background: '$blackA8',
},
display: "flex",
userSelect: "none",
touchAction: "none",
padding: "2px",
background: "$blackA6",
transition: "background 160ms ease-out",
"&:hover": {
background: "$blackA8",
},
});
const StyledThumb = styled(ScrollArea.Thumb, {
flex: '1',
background: '$teal6',
borderRadius: '10px',
position: 'relative',
'&:hover': {
background: '$teal8',
},
flex: "1",
background: "$teal6",
borderRadius: "10px",
position: "relative",
"&:hover": {
background: "$teal8",
},
});
function Scrollbar({
vertical,
horizontal,
root,
viewport,
children,
vertical,
horizontal,
root,
viewport,
children,
}: React.PropsWithChildren<ScrollbarProps>): React.ReactElement {
return (
<ScrollArea.Root style={root ?? {}}>
<ScrollArea.Viewport style={viewport ?? {}}>
{children}
</ScrollArea.Viewport>
{vertical ? (
<StyledScrollbar orientation="vertical" style={{ width: '10px' }}>
<StyledThumb />
</StyledScrollbar>
) : null}
{horizontal ? (
<StyledScrollbar
orientation="horizontal"
style={{ flexDirection: 'column', height: '10px' }}
>
<StyledThumb />
</StyledScrollbar>
) : null}
<ScrollArea.Corner />
</ScrollArea.Root>
);
return (
<ScrollArea.Root style={root ?? {}}>
<ScrollArea.Viewport style={viewport ?? {}}>{children}</ScrollArea.Viewport>
{vertical ? (
<StyledScrollbar orientation="vertical" style={{ width: "10px" }}>
<StyledThumb />
</StyledScrollbar>
) : null}
{horizontal ? (
<StyledScrollbar
orientation="horizontal"
style={{ flexDirection: "column", height: "10px" }}
>
<StyledThumb />
</StyledScrollbar>
) : null}
<ScrollArea.Corner />
</ScrollArea.Root>
);
}
const PureScrollbar = React.memo(Scrollbar);

View File

@ -1,36 +1,36 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { styled } from '../../theme';
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { styled } from "../../theme";
const WIPNotice = styled('div', {
marginTop: '2rem',
border: '1px solid $yellow7',
borderRadius: '0.25rem',
backgroundColor: '$yellow5',
padding: '0.5rem',
const WIPNotice = styled("div", {
marginTop: "2rem",
border: "1px solid $yellow7",
borderRadius: "0.25rem",
backgroundColor: "$yellow5",
padding: "0.5rem",
});
const WIPTitle = styled('div', {
fontWeight: 'bold',
color: '$yellow11',
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
const WIPTitle = styled("div", {
fontWeight: "bold",
color: "$yellow11",
marginBottom: "0.5rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
});
function WIP(): React.ReactElement {
const { t } = useTranslation();
return (
<WIPNotice>
<WIPTitle>
<ExclamationTriangleIcon />
{t('special.wip.header')}
<ExclamationTriangleIcon />
</WIPTitle>
{t('special.wip.text')}
</WIPNotice>
);
const { t } = useTranslation();
return (
<WIPNotice>
<WIPTitle>
<ExclamationTriangleIcon />
{t("special.wip.header")}
<ExclamationTriangleIcon />
</WIPTitle>
{t("special.wip.text")}
</WIPNotice>
);
}
const PureWIP = React.memo(WIP);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,182 +1,173 @@
import React from 'react';
import { CheckIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react';
import apiReducer, { modules } from '~/store/api/reducer';
import { useAppDispatch } from '~/store';
import type React from "react";
import { CheckIcon } from "@radix-ui/react-icons";
import { useTranslation } from "react-i18next";
import { useModule, useTimedStatus } from "~/lib/react";
import apiReducer, { modules } from "~/store/api/reducer";
import { useAppDispatch } from "~/store";
import {
PageContainer,
PageHeader,
PageTitle,
TextBlock,
Field,
FlexRow,
Label,
Checkbox,
CheckboxIndicator,
InputBox,
FieldNote,
} from '../../theme';
import SaveButton from '../../components/forms/SaveButton';
import Interval from '../../components/forms/Interval';
PageContainer,
PageHeader,
PageTitle,
TextBlock,
Field,
FlexRow,
Label,
Checkbox,
CheckboxIndicator,
InputBox,
FieldNote,
} from "../../theme";
import SaveButton from "../../components/forms/SaveButton";
import Interval from "../../components/forms/Interval";
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 { t } = useTranslation();
const [config, setConfig, loadStatus] = useModule(modules.loyaltyConfig);
const dispatch = useAppDispatch();
const status = useTimedStatus(loadStatus.save);
const busy = loadStatus.load?.type !== "success" || loadStatus.save?.type === "pending";
const active = config?.enabled ?? false;
const active = config?.enabled ?? false;
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.loyalty-settings.title')}</PageTitle>
<TextBlock>{t('pages.loyalty-settings.subtitle')}</TextBlock>
<TextBlock>{t('pages.loyalty-settings.note')}</TextBlock>
<Field css={{ paddingTop: '1rem' }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setConfig({
...config,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable">{t('pages.loyalty-settings.enable')}</Label>
</FlexRow>
</Field>
</PageHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
void dispatch(setConfig(config));
}}
>
<Field size="fullWidth">
<Label htmlFor="currency">
{t('pages.loyalty-settings.currency-name')}
</Label>
<InputBox
type="text"
id="currency"
placeholder={t('pages.loyalty-settings.currency-placeholder')}
value={config?.currency ?? ''}
disabled={!active || busy}
required={true}
onChange={(e) => {
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
currency: e.target.value,
}),
);
}}
/>
<FieldNote>
{t('pages.loyalty-settings.currency-name-hint')}
</FieldNote>
</Field>
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.loyalty-settings.title")}</PageTitle>
<TextBlock>{t("pages.loyalty-settings.subtitle")}</TextBlock>
<TextBlock>{t("pages.loyalty-settings.note")}</TextBlock>
<Field css={{ paddingTop: "1rem" }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setConfig({
...config,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable">{t("pages.loyalty-settings.enable")}</Label>
</FlexRow>
</Field>
</PageHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
void dispatch(setConfig(config));
}}
>
<Field size="fullWidth">
<Label htmlFor="currency">{t("pages.loyalty-settings.currency-name")}</Label>
<InputBox
type="text"
id="currency"
placeholder={t("pages.loyalty-settings.currency-placeholder")}
value={config?.currency ?? ""}
disabled={!active || busy}
required={true}
onChange={(e) => {
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
currency: e.target.value,
}),
);
}}
/>
<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'),
})}
</Label>
<FlexRow align="left" spacing={1}>
<InputBox
type="number"
id="reward"
placeholder={'0'}
css={{ maxWidth: '5rem' }}
defaultValue={config?.points?.amount}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
amount: intNum,
},
}),
);
}}
/>
<div>{t('pages.loyalty-settings.every')}</div>
<Interval
id="timer-interval"
value={config?.points?.interval ?? 120}
onChange={(interval) => {
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...(config ?? {}),
points: {
...config?.points,
interval,
},
}),
);
}}
active={active && !busy}
min={5}
required={true}
/>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label htmlFor="reward">
{t("pages.loyalty-settings.reward", {
currency: config?.currency ?? t("pages.loyalty-settings.currency-placeholder"),
})}
</Label>
<FlexRow align="left" spacing={1}>
<InputBox
type="number"
id="reward"
placeholder={"0"}
css={{ maxWidth: "5rem" }}
defaultValue={config?.points?.amount}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = Number.parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
amount: intNum,
},
}),
);
}}
/>
<div>{t("pages.loyalty-settings.every")}</div>
<Interval
id="timer-interval"
value={config?.points?.interval ?? 120}
onChange={(interval) => {
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...(config ?? {}),
points: {
...config?.points,
interval,
},
}),
);
}}
active={active && !busy}
min={5}
required={true}
/>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label htmlFor="bonus">
{t('pages.loyalty-settings.bonus-points')}
</Label>
<InputBox
type="number"
id="bonus"
placeholder={'0'}
defaultValue={config?.points?.activity_bonus}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
activity_bonus: intNum,
},
}),
);
}}
/>
<FieldNote>{t('pages.loyalty-settings.bonus-points-hint')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="bonus">{t("pages.loyalty-settings.bonus-points")}</Label>
<InputBox
type="number"
id="bonus"
placeholder={"0"}
defaultValue={config?.points?.activity_bonus}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = Number.parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
void dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
activity_bonus: intNum,
},
}),
);
}}
/>
<FieldNote>{t("pages.loyalty-settings.bonus-points-hint")}</FieldNote>
</Field>
<SaveButton type="submit" status={status} />
</form>
</PageContainer>
);
<SaveButton type="submit" status={status} />
</form>
</PageContainer>
);
}

View File

@ -1,418 +1,391 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule, useUserPoints } from '~/lib/react';
import { SortFunction } from '~/lib/types';
import { useAppDispatch } from '~/store';
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
import { LoyaltyRedeem } from '~/store/api/types';
import { DataTable } from '../../components/DataTable';
import DialogContent from '../../components/DialogContent';
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useModule, useUserPoints } from "~/lib/react";
import type { SortFunction } from "~/lib/types";
import { useAppDispatch } from "~/store";
import { modules, removeRedeem, setUserPoints } from "~/store/api/reducer";
import type { LoyaltyRedeem } from "~/store/api/types";
import { DataTable } from "../../components/DataTable";
import DialogContent from "../../components/DialogContent";
import {
Button,
Dialog,
DialogActions,
Field,
FlexRow,
InputBox,
Label,
NoneText,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../../theme';
import { TableCell, TableRow } from '../../theme/table';
Button,
Dialog,
DialogActions,
Field,
FlexRow,
InputBox,
Label,
NoneText,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from "../../theme";
import { TableCell, TableRow } from "../../theme/table";
function RewardQueueRow({ data }: { data: LoyaltyRedeem & { date: Date } }) {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { t } = useTranslation();
return (
<TableRow key={`${data.when.toString()}${data.username}`}>
<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>
<TableCell>
<FlexRow spacing="1">
<Button
size="small"
onClick={() => {
void dispatch(removeRedeem(data));
}}
>
{t('pages.loyalty-queue.accept')}
</Button>
<Button
size="small"
onClick={() => {
// Give points back to the viewer
void dispatch(
setUserPoints({
user: data.username,
points: data.reward.price,
relative: true,
}),
);
// Take the redeem off the list
void dispatch(removeRedeem(data));
}}
>
{t('pages.loyalty-queue.refund')}
</Button>
</FlexRow>
</TableCell>
</TableRow>
);
return (
<TableRow key={`${data.when.toString()}${data.username}`}>
<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>
<TableCell>
<FlexRow spacing="1">
<Button
size="small"
onClick={() => {
void dispatch(removeRedeem(data));
}}
>
{t("pages.loyalty-queue.accept")}
</Button>
<Button
size="small"
onClick={() => {
// Give points back to the viewer
void dispatch(
setUserPoints({
user: data.username,
points: data.reward.price,
relative: true,
}),
);
// Take the redeem off the list
void dispatch(removeRedeem(data));
}}
>
{t("pages.loyalty-queue.refund")}
</Button>
</FlexRow>
</TableCell>
</TableRow>
);
}
function RewardQueue() {
const { t } = useTranslation();
const [queue] = useModule(modules.loyaltyRedeemQueue);
const { t } = useTranslation();
const [queue] = useModule(modules.loyaltyRedeemQueue);
// Big hack but this is required or refunds break
useUserPoints();
// Big hack but this is required or refunds break
useUserPoints();
const data = queue?.map((q) => ({ ...q, date: new Date(q.when) })) ?? [];
type Redeem = (typeof data)[0];
const data = queue?.map((q) => ({ ...q, date: new Date(q.when) })) ?? [];
type Redeem = (typeof data)[0];
const sortfn = (key: keyof Redeem) => (a: Redeem, b: Redeem) => {
switch (key) {
case 'display_name': {
return a.display_name?.localeCompare(b.display_name);
}
case 'when': {
return a.date && b.date ? a.date.getTime() - b.date.getTime() : 0;
}
case 'reward': {
return a.reward?.name?.localeCompare(b.reward.name);
}
default:
return 0;
}
};
const sortfn = (key: keyof Redeem) => (a: Redeem, b: Redeem) => {
switch (key) {
case "display_name": {
return a.display_name?.localeCompare(b.display_name);
}
case "when": {
return a.date && b.date ? a.date.getTime() - b.date.getTime() : 0;
}
case "reward": {
return a.reward?.name?.localeCompare(b.reward.name);
}
default:
return 0;
}
};
return (
<>
{(data.length > 0 && (
<DataTable
sort={sortfn}
data={data}
keyFunction={(d) => `${d.when.toString()}/${d.username}`}
columns={[
{
key: 'when',
title: t('pages.loyalty-queue.date'),
sortable: true,
},
{
key: 'username',
title: t('pages.loyalty-queue.username'),
sortable: true,
},
{
key: 'reward',
title: t('pages.loyalty-queue.reward'),
sortable: true,
attr: {
style: {
textTransform: 'capitalize',
},
},
},
{
key: 'request_text',
title: t('pages.loyalty-queue.request'),
sortable: false,
attr: {
style: {
width: '100%',
textAlign: 'left',
},
},
},
{
key: 'actions',
title: '',
sortable: false,
},
]}
defaultSort={{ key: 'when', order: 'desc' }}
rowComponent={RewardQueueRow}
/>
)) || <NoneText>{t('pages.loyalty-queue.no-redeems')}</NoneText>}
</>
);
return (
<>
{(data.length > 0 && (
<DataTable
sort={sortfn}
data={data}
keyFunction={(d) => `${d.when.toString()}/${d.username}`}
columns={[
{
key: "when",
title: t("pages.loyalty-queue.date"),
sortable: true,
},
{
key: "username",
title: t("pages.loyalty-queue.username"),
sortable: true,
},
{
key: "reward",
title: t("pages.loyalty-queue.reward"),
sortable: true,
attr: {
style: {
textTransform: "capitalize",
},
},
},
{
key: "request_text",
title: t("pages.loyalty-queue.request"),
sortable: false,
attr: {
style: {
width: "100%",
textAlign: "left",
},
},
},
{
key: "actions",
title: "",
sortable: false,
},
]}
defaultSort={{ key: "when", order: "desc" }}
rowComponent={RewardQueueRow}
/>
)) || <NoneText>{t("pages.loyalty-queue.no-redeems")}</NoneText>}
</>
);
}
function UserList() {
const { t } = useTranslation();
const users = useUserPoints();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const users = useUserPoints();
const dispatch = useAppDispatch();
const [currentEntry, setCurrentEntry] = useState<UserEntry>(null);
const [givePointDialog, setGivePointDialog] = useState({
open: false,
user: '',
points: 0,
});
const [config] = useModule(modules.loyaltyConfig);
const [usernameFilter, setUsernameFilter] = useState('');
const filtered = Object.entries(users ?? [])
.filter(([user]) => user.includes(usernameFilter))
.map(([username, data]) => ({
username,
...data,
}));
type UserEntry = (typeof filtered)[0];
const [currentEntry, setCurrentEntry] = useState<UserEntry>(null);
const [givePointDialog, setGivePointDialog] = useState({
open: false,
user: "",
points: 0,
});
const [config] = useModule(modules.loyaltyConfig);
const [usernameFilter, setUsernameFilter] = useState("");
const filtered = Object.entries(users ?? [])
.filter(([user]) => user.includes(usernameFilter))
.map(([username, data]) => ({
username,
...data,
}));
type UserEntry = (typeof filtered)[0];
const sortfn = (key: keyof UserEntry): SortFunction<UserEntry> => {
switch (key) {
case 'username': {
return (a, b) => a.username.localeCompare(b.username);
}
case 'points': {
return (a: UserEntry, b: UserEntry) => a.points - b.points;
}
}
};
const sortfn = (key: keyof UserEntry): SortFunction<UserEntry> => {
switch (key) {
case "username": {
return (a, b) => a.username.localeCompare(b.username);
}
case "points": {
return (a: UserEntry, b: UserEntry) => a.points - b.points;
}
}
};
const UserListRow = ({ data }: { data: UserEntry }) => (
<TableRow key={data.username}>
<TableCell css={{ width: '100%' }}>{data.username}</TableCell>
<TableCell>{data.points}</TableCell>
<TableCell>
<Button onClick={() => setCurrentEntry(data)} size="small">
{t('form-actions.edit')}
</Button>
</TableCell>
</TableRow>
);
const UserListRow = ({ data }: { data: UserEntry }) => (
<TableRow key={data.username}>
<TableCell css={{ width: "100%" }}>{data.username}</TableCell>
<TableCell>{data.points}</TableCell>
<TableCell>
<Button onClick={() => setCurrentEntry(data)} size="small">
{t("form-actions.edit")}
</Button>
</TableCell>
</TableRow>
);
return (
<>
<Dialog
open={givePointDialog.open}
onOpenChange={(state) =>
setGivePointDialog({ ...givePointDialog, open: state })
}
>
<DialogContent
title={t('pages.loyalty-queue.give-points-dialog')}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void dispatch(
setUserPoints({
...givePointDialog,
user: givePointDialog.user.toLowerCase(),
relative: true,
}),
);
setGivePointDialog({ ...givePointDialog, open: false });
}
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-username">
{t('pages.loyalty-queue.username')}
</Label>
<InputBox
id="d-username"
required={true}
value={givePointDialog?.user ?? ''}
onChange={(e) =>
setGivePointDialog({
...givePointDialog,
user: e.target.value,
})
}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-points" css={{ textTransform: 'capitalize' }}>
{config?.currency || t('pages.loyalty-queue.points')}
</Label>
<InputBox
type="number"
id="d-points"
value={givePointDialog?.points ?? '0'}
onChange={(e) =>
setGivePointDialog({
...givePointDialog,
points: parseInt(e.target.value, 10),
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t('form-actions.save')}
</Button>
<Button
type="button"
onClick={() =>
setGivePointDialog({ ...givePointDialog, open: false })
}
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Dialog
open={currentEntry !== null}
onOpenChange={(state) => setCurrentEntry(state ? currentEntry : null)}
>
<DialogContent
title={t('pages.loyalty-queue.modify-balance-dialog')}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void dispatch(
setUserPoints({
user: currentEntry.username.toLowerCase(),
points: currentEntry.points,
relative: false,
}),
);
setCurrentEntry(null);
}
}}
>
<Field size="fullWidth" spacing="narrow">
<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' }}>
{config?.currency || t('pages.loyalty-queue.points')}
</Label>
<InputBox
type="number"
id="d-points"
value={currentEntry?.points ?? '0'}
onChange={(e) =>
setCurrentEntry({
...currentEntry,
points: parseInt(e.target.value, 10),
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t('form-actions.save')}
</Button>
<Button onClick={() => setCurrentEntry(null)} type="button">
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button
onClick={() =>
setGivePointDialog({ open: true, user: '', points: 0 })
}
>
{t('pages.loyalty-queue.give-points-dialog')}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.loyalty-queue.username-filter')}
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
/>
</FlexRow>
</Field>
{(filtered.length > 0 && (
<DataTable
sort={sortfn}
data={filtered}
keyFunction={(entry) => entry.username}
columns={[
{
key: 'username',
title: t('pages.loyalty-queue.username'),
sortable: true,
attr: {
style: {
width: '100%',
textAlign: 'left',
},
},
},
{
key: 'points',
title: config?.currency || t('pages.loyalty-queue.points'),
sortable: true,
attr: {
style: {
textTransform: 'capitalize',
},
},
},
{
key: 'actions',
title: '',
sortable: false,
},
]}
defaultSort={{ key: 'points', order: 'desc' }}
rowComponent={UserListRow}
/>
)) || <NoneText>{t('pages.loyalty-queue.no-users')}</NoneText>}
</>
);
return (
<>
<Dialog
open={givePointDialog.open}
onOpenChange={(state) => setGivePointDialog({ ...givePointDialog, open: state })}
>
<DialogContent title={t("pages.loyalty-queue.give-points-dialog")} closeButton={true}>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void dispatch(
setUserPoints({
...givePointDialog,
user: givePointDialog.user.toLowerCase(),
relative: true,
}),
);
setGivePointDialog({ ...givePointDialog, open: false });
}
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-username">{t("pages.loyalty-queue.username")}</Label>
<InputBox
id="d-username"
required={true}
value={givePointDialog?.user ?? ""}
onChange={(e) =>
setGivePointDialog({
...givePointDialog,
user: e.target.value,
})
}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-points" css={{ textTransform: "capitalize" }}>
{config?.currency || t("pages.loyalty-queue.points")}
</Label>
<InputBox
type="number"
id="d-points"
value={givePointDialog?.points ?? "0"}
onChange={(e) =>
setGivePointDialog({
...givePointDialog,
points: Number.parseInt(e.target.value, 10),
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t("form-actions.save")}
</Button>
<Button
type="button"
onClick={() => setGivePointDialog({ ...givePointDialog, open: false })}
>
{t("form-actions.cancel")}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Dialog
open={currentEntry !== null}
onOpenChange={(state) => setCurrentEntry(state ? currentEntry : null)}
>
<DialogContent title={t("pages.loyalty-queue.modify-balance-dialog")} closeButton={true}>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void dispatch(
setUserPoints({
user: currentEntry.username.toLowerCase(),
points: currentEntry.points,
relative: false,
}),
);
setCurrentEntry(null);
}
}}
>
<Field size="fullWidth" spacing="narrow">
<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" }}>
{config?.currency || t("pages.loyalty-queue.points")}
</Label>
<InputBox
type="number"
id="d-points"
value={currentEntry?.points ?? "0"}
onChange={(e) =>
setCurrentEntry({
...currentEntry,
points: Number.parseInt(e.target.value, 10),
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t("form-actions.save")}
</Button>
<Button onClick={() => setCurrentEntry(null)} type="button">
{t("form-actions.cancel")}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: "stretch" }} spacing="1">
<Button onClick={() => setGivePointDialog({ open: true, user: "", points: 0 })}>
{t("pages.loyalty-queue.give-points-dialog")}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t("pages.loyalty-queue.username-filter")}
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
/>
</FlexRow>
</Field>
{(filtered.length > 0 && (
<DataTable
sort={sortfn}
data={filtered}
keyFunction={(entry) => entry.username}
columns={[
{
key: "username",
title: t("pages.loyalty-queue.username"),
sortable: true,
attr: {
style: {
width: "100%",
textAlign: "left",
},
},
},
{
key: "points",
title: config?.currency || t("pages.loyalty-queue.points"),
sortable: true,
attr: {
style: {
textTransform: "capitalize",
},
},
},
{
key: "actions",
title: "",
sortable: false,
},
]}
defaultSort={{ key: "points", order: "desc" }}
rowComponent={UserListRow}
/>
)) || <NoneText>{t("pages.loyalty-queue.no-users")}</NoneText>}
</>
);
}
export default function LoyaltyQueuePage(): React.ReactElement {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.loyalty-queue.title')}</PageTitle>
<TextBlock>{t('pages.loyalty-queue.subtitle')}</TextBlock>
</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>
</TabList>
<TabContent value="queue">
<RewardQueue />
</TabContent>
<TabContent value="users">
<UserList />
</TabContent>
</TabContainer>
</PageContainer>
);
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.loyalty-queue.title")}</PageTitle>
<TextBlock>{t("pages.loyalty-queue.subtitle")}</TextBlock>
</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>
</TabList>
<TabContent value="queue">
<RewardQueue />
</TabContent>
<TabContent value="users">
<UserList />
</TabContent>
</TabContainer>
</PageContainer>
);
}

View File

@ -1,380 +1,348 @@
import { PlusIcon } from '@radix-ui/react-icons';
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 { LoyaltyGoal } from '~/store/api/types';
import AlertContent from '../../../components/AlertContent';
import DialogContent from '../../../components/DialogContent';
import { PlusIcon } from "@radix-ui/react-icons";
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 type { LoyaltyGoal } from "~/store/api/types";
import AlertContent from "../../../components/AlertContent";
import DialogContent from "../../../components/DialogContent";
import {
Button,
ControlledInputBox,
Dialog,
DialogActions,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
styled,
Textarea,
} from '../../../theme';
import { Alert, AlertTrigger } from '../../../theme/alert';
Button,
ControlledInputBox,
Dialog,
DialogActions,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
styled,
Textarea,
} from "../../../theme";
import { Alert, AlertTrigger } from "../../../theme/alert";
import {
RewardActions,
RewardCost,
RewardDescription,
RewardHeader,
RewardID,
RewardIcon,
RewardItemContainer,
RewardName,
} from './theme';
RewardActions,
RewardCost,
RewardDescription,
RewardHeader,
RewardID,
RewardIcon,
RewardItemContainer,
RewardName,
} from "./theme";
const GoalList = styled('div', { marginTop: '1rem' });
const GoalList = styled("div", { marginTop: "1rem" });
interface GoalItemProps {
name: string;
item: LoyaltyGoal;
currency: string;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
name: string;
item: LoyaltyGoal;
currency: string;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
function GoalItem({
name,
item,
currency,
onToggle,
onEdit,
onDelete,
name,
item,
currency,
onToggle,
onEdit,
onDelete,
}: GoalItemProps): React.ReactElement {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
<RewardHeader>
<RewardIcon>
{item.image && (
<img
src={item.image}
style={{ width: '32px', borderRadius: '0.25rem' }}
/>
)}
</RewardIcon>
<RewardName status={item.enabled ? 'enabled' : 'disabled'}>
{item.name} (<RewardID>{name}</RewardID>)
</RewardName>
<RewardCost>
{item.contributed} / {item.total} {currency} (
{Math.round((item.contributed / item.total) * 100)}%)
</RewardCost>
<RewardActions>
<MultiButton>
<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)}
>
{t('form-actions.edit')}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t('form-actions.delete')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.loyalty-rewards.remove-reward-title', {
name: item.name,
})}
description={t('form-actions.warning-delete')}
actionText={t('form-actions.delete')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</RewardActions>
</RewardHeader>
<RewardDescription>{item.description}</RewardDescription>
</RewardItemContainer>
);
return (
<RewardItemContainer status={item.enabled ? "enabled" : "disabled"}>
<RewardHeader>
<RewardIcon>
{item.image && (
<img
aria-label={item.name}
src={item.image}
style={{ width: "32px", borderRadius: "0.25rem" }}
/>
)}
</RewardIcon>
<RewardName status={item.enabled ? "enabled" : "disabled"}>
{item.name} (<RewardID>{name}</RewardID>)
</RewardName>
<RewardCost>
{item.contributed} / {item.total} {currency} (
{Math.round((item.contributed / item.total) * 100)}%)
</RewardCost>
<RewardActions>
<MultiButton>
<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)}>
{t("form-actions.edit")}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t("form-actions.delete")}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t("pages.loyalty-rewards.remove-reward-title", {
name: item.name,
})}
description={t("form-actions.warning-delete")}
actionText={t("form-actions.delete")}
actionButtonProps={{ variation: "danger" }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</RewardActions>
</RewardHeader>
<RewardDescription>{item.description}</RewardDescription>
</RewardItemContainer>
);
}
export function GoalsTab() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [config] = useModule(modules.loyaltyConfig);
const [goals, setGoals] = useModule(modules.loyaltyGoals);
const [filter, setFilter] = useState('');
const [dialogGoal, setDialogGoal] = useState<{
open: boolean;
new: boolean;
goal: LoyaltyGoal;
}>({ open: false, new: false, goal: null });
const filterLC = filter.toLowerCase();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [config] = useModule(modules.loyaltyConfig);
const [goals, setGoals] = useModule(modules.loyaltyGoals);
const [filter, setFilter] = useState("");
const [dialogGoal, setDialogGoal] = useState<{
open: boolean;
new: boolean;
goal: LoyaltyGoal;
}>({ open: false, new: false, goal: null });
const filterLC = filter.toLowerCase();
const deleteGoal = (id: string): void => {
void dispatch(setGoals(goals?.filter((r) => r.id !== id) ?? []));
};
const deleteGoal = (id: string): void => {
void dispatch(setGoals(goals?.filter((r) => r.id !== id) ?? []));
};
const toggleGoal = (id: string): void => {
void dispatch(
setGoals(
goals?.map((r) => {
if (r.id === id) {
return {
...r,
enabled: !r.enabled,
};
}
return r;
}) ?? [],
),
);
};
const toggleGoal = (id: string): void => {
void dispatch(
setGoals(
goals?.map((r) => {
if (r.id === id) {
return {
...r,
enabled: !r.enabled,
};
}
return r;
}) ?? [],
),
);
};
return (
<>
<Dialog
open={dialogGoal.open}
onOpenChange={(state) => setDialogGoal({ ...dialogGoal, open: state })}
>
<DialogContent
title={
dialogGoal.new
? t('pages.loyalty-rewards.create-goal')
: t('pages.loyalty-rewards.edit-goal')
}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
const { goal } = dialogGoal;
const index = goals?.findIndex((g) => g.id === goal.id);
if (index >= 0) {
const newGoals = goals.slice(0);
newGoals[index] = goal;
void dispatch(setGoals(newGoals));
} else {
void dispatch(setGoals([...(goals ?? []), goal]));
}
setDialogGoal({ ...dialogGoal, open: false });
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-id">
{t('pages.loyalty-rewards.goal-id')}
</Label>
<ControlledInputBox
id="goal-id"
type="text"
required
disabled={!dialogGoal.new}
value={dialogGoal?.goal?.id}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
id:
e.target.value
?.toLowerCase()
.replace(/[^a-z0-9]/gi, '-') ?? '',
},
});
if (
dialogGoal.new &&
goals.find((r) => r.id === e.target.value)
) {
(e.target as HTMLInputElement).setCustomValidity(
t('pages.loyalty-rewards.id-already-in-use'),
);
} else {
(e.target as HTMLInputElement).setCustomValidity('');
}
}}
/>
<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>
<InputBox
id="goal-name"
type="text"
required
value={dialogGoal?.goal?.name ?? ''}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
name: e.target.value,
},
});
}}
/>
<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>
<InputBox
id="goal-icon"
type="text"
value={dialogGoal?.goal?.image ?? ''}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
image: e.target.value,
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-desc">
{t('pages.loyalty-rewards.goal-desc')}
</Label>
<Textarea
id="goal-desc"
value={dialogGoal?.goal?.description ?? ''}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
description: e.target.value,
},
});
}}
>
{dialogGoal?.goal?.description ?? ''}
</Textarea>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-cost">
{t('pages.loyalty-rewards.goal-cost')}
</Label>
<InputBox
id="goal-cost"
type="number"
required
defaultValue={dialogGoal?.goal?.total}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
total: parseInt(e.target.value, 10),
},
});
}}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{dialogGoal.new
? t('form-actions.create')
: t('form-actions.edit')}
</Button>
<Button
type="button"
onClick={() => setDialogGoal({ ...dialogGoal, open: false })}
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button
variation="primary"
onClick={() => {
setDialogGoal({
open: true,
new: true,
goal: {
id: '',
enabled: true,
name: '',
description: '',
image: '',
total: 0,
contributed: 0,
contributors: {},
},
});
}}
>
<PlusIcon /> {t('pages.loyalty-rewards.create-goal')}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.loyalty-rewards.goal-filter')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
<GoalList>
{goals && goals.length > 0 ? (
goals
?.filter(
(r) =>
r.name.toLowerCase().includes(filterLC) ||
r.id.toLowerCase().includes(filterLC) ||
r.description.toLowerCase().includes(filterLC),
)
.map((r) => (
<GoalItem
key={r.id}
name={r.id}
item={r}
currency={(
config?.currency || t('pages.loyalty-queue.points')
).toLowerCase()}
onEdit={() =>
setDialogGoal({
open: true,
new: false,
goal: r,
})
}
onDelete={() => deleteGoal(r.id)}
onToggle={() => toggleGoal(r.id)}
/>
))
) : (
<NoneText>{t('pages.loyalty-rewards.no-goals')}</NoneText>
)}
</GoalList>
</>
);
return (
<>
<Dialog
open={dialogGoal.open}
onOpenChange={(state) => setDialogGoal({ ...dialogGoal, open: state })}
>
<DialogContent
title={
dialogGoal.new
? t("pages.loyalty-rewards.create-goal")
: t("pages.loyalty-rewards.edit-goal")
}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
const { goal } = dialogGoal;
const index = goals?.findIndex((g) => g.id === goal.id);
if (index >= 0) {
const newGoals = goals.slice(0);
newGoals[index] = goal;
void dispatch(setGoals(newGoals));
} else {
void dispatch(setGoals([...(goals ?? []), goal]));
}
setDialogGoal({ ...dialogGoal, open: false });
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-id">{t("pages.loyalty-rewards.goal-id")}</Label>
<ControlledInputBox
id="goal-id"
type="text"
required
disabled={!dialogGoal.new}
value={dialogGoal?.goal?.id}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
id: e.target.value?.toLowerCase().replace(/[^a-z0-9]/gi, "-") ?? "",
},
});
if (dialogGoal.new && goals.find((r) => r.id === e.target.value)) {
(e.target as HTMLInputElement).setCustomValidity(
t("pages.loyalty-rewards.id-already-in-use"),
);
} else {
(e.target as HTMLInputElement).setCustomValidity("");
}
}}
/>
<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>
<InputBox
id="goal-name"
type="text"
required
value={dialogGoal?.goal?.name ?? ""}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
name: e.target.value,
},
});
}}
/>
<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>
<InputBox
id="goal-icon"
type="text"
value={dialogGoal?.goal?.image ?? ""}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
image: e.target.value,
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-desc">{t("pages.loyalty-rewards.goal-desc")}</Label>
<Textarea
id="goal-desc"
value={dialogGoal?.goal?.description ?? ""}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
description: e.target.value,
},
});
}}
>
{dialogGoal?.goal?.description ?? ""}
</Textarea>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="goal-cost">{t("pages.loyalty-rewards.goal-cost")}</Label>
<InputBox
id="goal-cost"
type="number"
required
defaultValue={dialogGoal?.goal?.total}
onChange={(e) => {
setDialogGoal({
...dialogGoal,
goal: {
...dialogGoal?.goal,
total: Number.parseInt(e.target.value, 10),
},
});
}}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{dialogGoal.new ? t("form-actions.create") : t("form-actions.edit")}
</Button>
<Button type="button" onClick={() => setDialogGoal({ ...dialogGoal, open: false })}>
{t("form-actions.cancel")}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: "stretch" }} spacing="1">
<Button
variation="primary"
onClick={() => {
setDialogGoal({
open: true,
new: true,
goal: {
id: "",
enabled: true,
name: "",
description: "",
image: "",
total: 0,
contributed: 0,
contributors: {},
},
});
}}
>
<PlusIcon /> {t("pages.loyalty-rewards.create-goal")}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t("pages.loyalty-rewards.goal-filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
<GoalList>
{goals && goals.length > 0 ? (
goals
?.filter(
(r) =>
r.name.toLowerCase().includes(filterLC) ||
r.id.toLowerCase().includes(filterLC) ||
r.description.toLowerCase().includes(filterLC),
)
.map((r) => (
<GoalItem
key={r.id}
name={r.id}
item={r}
currency={(config?.currency || t("pages.loyalty-queue.points")).toLowerCase()}
onEdit={() =>
setDialogGoal({
open: true,
new: false,
goal: r,
})
}
onDelete={() => deleteGoal(r.id)}
onToggle={() => toggleGoal(r.id)}
/>
))
) : (
<NoneText>{t("pages.loyalty-rewards.no-goals")}</NoneText>
)}
</GoalList>
</>
);
}

View File

@ -1,42 +1,38 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import {
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../../../theme';
import { GoalsTab } from './GoalsTab';
import { RewardsTab } from './RewardsTab';
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from "../../../theme";
import { GoalsTab } from "./GoalsTab";
import { RewardsTab } from "./RewardsTab";
export default function LoyaltyRewardsPage(): React.ReactElement {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.loyalty-rewards.title')}</PageTitle>
<TextBlock>{t('pages.loyalty-rewards.subtitle')}</TextBlock>
</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>
</TabList>
<TabContent value="rewards">
<RewardsTab />
</TabContent>
<TabContent value="goals">
<GoalsTab />
</TabContent>
</TabContainer>
</PageContainer>
);
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.loyalty-rewards.title")}</PageTitle>
<TextBlock>{t("pages.loyalty-rewards.subtitle")}</TextBlock>
</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>
</TabList>
<TabContent value="rewards">
<RewardsTab />
</TabContent>
<TabContent value="goals">
<GoalsTab />
</TabContent>
</TabContainer>
</PageContainer>
);
}

View File

@ -1,457 +1,413 @@
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
import React, { 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 AlertContent from '../../../components/AlertContent';
import DialogContent from '../../../components/DialogContent';
import Interval from '../../../components/forms/Interval';
import { CheckIcon, PlusIcon } from "@radix-ui/react-icons";
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 type { LoyaltyReward } from "~/store/api/types";
import AlertContent from "../../../components/AlertContent";
import DialogContent from "../../../components/DialogContent";
import Interval from "../../../components/forms/Interval";
import {
Button,
Checkbox,
CheckboxIndicator,
ControlledInputBox,
Dialog,
DialogActions,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
styled,
Textarea,
} from '../../../theme';
import { Alert, AlertTrigger } from '../../../theme/alert';
Button,
Checkbox,
CheckboxIndicator,
ControlledInputBox,
Dialog,
DialogActions,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
styled,
Textarea,
} from "../../../theme";
import { Alert, AlertTrigger } from "../../../theme/alert";
import {
RewardItemContainer,
RewardHeader,
RewardIcon,
RewardName,
RewardID,
RewardCost,
RewardActions,
RewardDescription,
} from './theme';
RewardItemContainer,
RewardHeader,
RewardIcon,
RewardName,
RewardID,
RewardCost,
RewardActions,
RewardDescription,
} from "./theme";
const RewardList = styled('div', { marginTop: '1rem' });
const RewardList = styled("div", { marginTop: "1rem" });
interface RewardItemProps {
name: string;
item: LoyaltyReward;
currency: string;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
name: string;
item: LoyaltyReward;
currency: string;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
function RewardItem({
name,
item,
currency,
onToggle,
onEdit,
onDelete,
name,
item,
currency,
onToggle,
onEdit,
onDelete,
}: RewardItemProps): React.ReactElement {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<RewardItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
<RewardHeader>
<RewardIcon>
{item.image && (
<img
src={item.image}
style={{ width: '32px', borderRadius: '0.25rem' }}
/>
)}
</RewardIcon>
<RewardName status={item.enabled ? 'enabled' : 'disabled'}>
{item.name} (<RewardID>{name}</RewardID>)
</RewardName>
<RewardCost>
{item.price} {currency}
</RewardCost>
<RewardActions>
<MultiButton>
<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)}
>
{t('form-actions.edit')}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t('form-actions.delete')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.loyalty-rewards.remove-reward-title', {
name: item.name,
})}
description={t('form-actions.warning-delete')}
actionText={t('form-actions.delete')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</RewardActions>
</RewardHeader>
<RewardDescription>{item.description}</RewardDescription>
</RewardItemContainer>
);
return (
<RewardItemContainer status={item.enabled ? "enabled" : "disabled"}>
<RewardHeader>
<RewardIcon>
{item.image && (
<img
alt={item.name}
src={item.image}
style={{ width: "32px", borderRadius: "0.25rem" }}
/>
)}
</RewardIcon>
<RewardName status={item.enabled ? "enabled" : "disabled"}>
{item.name} (<RewardID>{name}</RewardID>)
</RewardName>
<RewardCost>
{item.price} {currency}
</RewardCost>
<RewardActions>
<MultiButton>
<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)}>
{t("form-actions.edit")}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t("form-actions.delete")}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t("pages.loyalty-rewards.remove-reward-title", {
name: item.name,
})}
description={t("form-actions.warning-delete")}
actionText={t("form-actions.delete")}
actionButtonProps={{ variation: "danger" }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</RewardActions>
</RewardHeader>
<RewardDescription>{item.description}</RewardDescription>
</RewardItemContainer>
);
}
export function RewardsTab() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [cursorPosition, setCursorPosition] = useState(0);
const [config] = useModule(modules.loyaltyConfig);
const [rewards, setRewards] = useModule(modules.loyaltyRewards);
const [filter, setFilter] = useState('');
const [dialogReward, setDialogReward] = useState<{
open: boolean;
new: boolean;
reward: LoyaltyReward;
}>({ open: false, new: false, reward: null });
const [requiredInfo, setRequiredInfo] = useState({
enabled: false,
text: '',
});
const filterLC = filter.toLowerCase();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [cursorPosition, setCursorPosition] = useState(0);
const [config] = useModule(modules.loyaltyConfig);
const [rewards, setRewards] = useModule(modules.loyaltyRewards);
const [filter, setFilter] = useState("");
const [dialogReward, setDialogReward] = useState<{
open: boolean;
new: boolean;
reward: LoyaltyReward;
}>({ open: false, new: false, reward: null });
const [requiredInfo, setRequiredInfo] = useState({
enabled: false,
text: "",
});
const filterLC = filter.toLowerCase();
const deleteReward = (id: string) => {
void dispatch(setRewards(rewards?.filter((r) => r.id !== id) ?? []));
};
const deleteReward = (id: string) => {
void dispatch(setRewards(rewards?.filter((r) => r.id !== id) ?? []));
};
const toggleReward = (id: string) => {
void dispatch(
setRewards(
rewards?.map((r) => {
if (r.id === id) {
return {
...r,
enabled: !r.enabled,
};
}
return r;
}) ?? [],
),
);
};
const toggleReward = (id: string) => {
void dispatch(
setRewards(
rewards?.map((r) => {
if (r.id === id) {
return {
...r,
enabled: !r.enabled,
};
}
return r;
}) ?? [],
),
);
};
return (
<>
<Dialog
open={dialogReward.open}
onOpenChange={(state) =>
setDialogReward({ ...dialogReward, open: state })
}
>
<DialogContent
title={
dialogReward.new
? t('pages.loyalty-rewards.create-reward')
: t('pages.loyalty-rewards.edit-reward')
}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
const { reward } = dialogReward;
if (requiredInfo.enabled) {
reward.required_info = requiredInfo.text;
}
const index = rewards?.findIndex((r) => r.id === reward.id);
if (index >= 0) {
const newRewards = rewards.slice(0);
newRewards[index] = reward;
void dispatch(setRewards(newRewards));
} else {
void dispatch(setRewards([...(rewards ?? []), reward]));
}
setDialogReward({ ...dialogReward, open: false });
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-id">
{t('pages.loyalty-rewards.reward-id')}
</Label>
<ControlledInputBox
id="reward-id"
type="text"
required
disabled={!dialogReward.new}
value={dialogReward?.reward?.id}
onFocus={(e) => {
e.target.selectionStart = cursorPosition;
}}
onChange={(e) => {
setCursorPosition(e.target.selectionStart);
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
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'),
);
} else {
e.target.setCustomValidity('');
}
}}
/>
<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>
<InputBox
id="reward-name"
type="text"
required
value={dialogReward?.reward?.name ?? ''}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
name: e.target.value,
},
});
}}
/>
<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>
<InputBox
id="reward-icon"
type="text"
value={dialogReward?.reward?.image ?? ''}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
image: e.target.value,
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-desc">
{t('pages.loyalty-rewards.reward-desc')}
</Label>
<Textarea
id="reward-desc"
value={dialogReward?.reward?.description ?? ''}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
description: e.target.value,
},
});
}}
>
{dialogReward?.reward?.description ?? ''}
</Textarea>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-cost">
{t('pages.loyalty-rewards.reward-cost')}
</Label>
<InputBox
id="reward-cost"
type="number"
required
defaultValue={dialogReward?.reward?.price}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
price: parseInt(e.target.value, 10),
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-cooldown">
{t('pages.loyalty-rewards.reward-cooldown')}
</Label>
<FlexRow align="left">
<Interval
value={dialogReward?.reward?.cooldown ?? 0}
active={true}
onChange={(cooldown) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
cooldown,
},
});
}}
/>
</FlexRow>
</Field>
<Field size="fullWidth" spacing="narrow">
<FlexRow align="left" spacing="1">
<Checkbox
id="reward-details"
checked={requiredInfo.enabled}
onCheckedChange={(e) => {
setRequiredInfo({
...requiredInfo,
enabled: !!e,
});
}}
>
<CheckboxIndicator>
{requiredInfo.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<Label htmlFor="reward-details">
{t('pages.loyalty-rewards.reward-details')}
</Label>
</FlexRow>
<InputBox
id="reward-details-text"
type="text"
disabled={!requiredInfo.enabled}
required={requiredInfo.enabled}
value={dialogReward?.reward?.required_info ?? ''}
placeholder={t(
'pages.loyalty-rewards.reward-details-placeholder',
)}
onChange={(e) => {
setRequiredInfo({ ...requiredInfo, text: e.target.value });
}}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{dialogReward.new
? t('form-actions.create')
: t('form-actions.edit')}
</Button>
<Button
type="button"
onClick={() =>
setDialogReward({ ...dialogReward, open: false })
}
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button
variation="primary"
onClick={() => {
setRequiredInfo({
enabled: false,
text: '',
});
setDialogReward({
open: true,
new: true,
reward: {
id: '',
enabled: true,
name: '',
description: '',
image: '',
price: 0,
cooldown: 0,
},
});
}}
>
<PlusIcon /> {t('pages.loyalty-rewards.create-reward')}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.loyalty-rewards.reward-filter')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
<RewardList>
{rewards && rewards.length > 0 ? (
rewards
?.filter(
(r) =>
r.name.toLowerCase().includes(filterLC) ||
r.id.toLowerCase().includes(filterLC) ||
r.description.toLowerCase().includes(filterLC),
)
.map((r) => (
<RewardItem
key={r.id}
name={r.id}
item={r}
currency={(
config?.currency || t('pages.loyalty-queue.points')
).toLowerCase()}
onEdit={() =>
setDialogReward({
open: true,
new: false,
reward: r,
})
}
onDelete={() => deleteReward(r.id)}
onToggle={() => toggleReward(r.id)}
/>
))
) : (
<NoneText>{t('pages.loyalty-rewards.no-rewards')}</NoneText>
)}
</RewardList>
</>
);
return (
<>
<Dialog
open={dialogReward.open}
onOpenChange={(state) => setDialogReward({ ...dialogReward, open: state })}
>
<DialogContent
title={
dialogReward.new
? t("pages.loyalty-rewards.create-reward")
: t("pages.loyalty-rewards.edit-reward")
}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
const { reward } = dialogReward;
if (requiredInfo.enabled) {
reward.required_info = requiredInfo.text;
}
const index = rewards?.findIndex((r) => r.id === reward.id);
if (index >= 0) {
const newRewards = rewards.slice(0);
newRewards[index] = reward;
void dispatch(setRewards(newRewards));
} else {
void dispatch(setRewards([...(rewards ?? []), reward]));
}
setDialogReward({ ...dialogReward, open: false });
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-id">{t("pages.loyalty-rewards.reward-id")}</Label>
<ControlledInputBox
id="reward-id"
type="text"
required
disabled={!dialogReward.new}
value={dialogReward?.reward?.id}
onFocus={(e) => {
e.target.selectionStart = cursorPosition;
}}
onChange={(e) => {
setCursorPosition(e.target.selectionStart);
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
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"));
} else {
e.target.setCustomValidity("");
}
}}
/>
<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>
<InputBox
id="reward-name"
type="text"
required
value={dialogReward?.reward?.name ?? ""}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
name: e.target.value,
},
});
}}
/>
<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>
<InputBox
id="reward-icon"
type="text"
value={dialogReward?.reward?.image ?? ""}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
image: e.target.value,
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-desc">{t("pages.loyalty-rewards.reward-desc")}</Label>
<Textarea
id="reward-desc"
value={dialogReward?.reward?.description ?? ""}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
description: e.target.value,
},
});
}}
>
{dialogReward?.reward?.description ?? ""}
</Textarea>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-cost">{t("pages.loyalty-rewards.reward-cost")}</Label>
<InputBox
id="reward-cost"
type="number"
required
defaultValue={dialogReward?.reward?.price}
onChange={(e) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
price: Number.parseInt(e.target.value, 10),
},
});
}}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="reward-cooldown">{t("pages.loyalty-rewards.reward-cooldown")}</Label>
<FlexRow align="left">
<Interval
value={dialogReward?.reward?.cooldown ?? 0}
active={true}
onChange={(cooldown) => {
setDialogReward({
...dialogReward,
reward: {
...dialogReward?.reward,
cooldown,
},
});
}}
/>
</FlexRow>
</Field>
<Field size="fullWidth" spacing="narrow">
<FlexRow align="left" spacing="1">
<Checkbox
id="reward-details"
checked={requiredInfo.enabled}
onCheckedChange={(e) => {
setRequiredInfo({
...requiredInfo,
enabled: !!e,
});
}}
>
<CheckboxIndicator>{requiredInfo.enabled && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="reward-details">{t("pages.loyalty-rewards.reward-details")}</Label>
</FlexRow>
<InputBox
id="reward-details-text"
type="text"
disabled={!requiredInfo.enabled}
required={requiredInfo.enabled}
value={dialogReward?.reward?.required_info ?? ""}
placeholder={t("pages.loyalty-rewards.reward-details-placeholder")}
onChange={(e) => {
setRequiredInfo({ ...requiredInfo, text: e.target.value });
}}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{dialogReward.new ? t("form-actions.create") : t("form-actions.edit")}
</Button>
<Button
type="button"
onClick={() => setDialogReward({ ...dialogReward, open: false })}
>
{t("form-actions.cancel")}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: "stretch" }} spacing="1">
<Button
variation="primary"
onClick={() => {
setRequiredInfo({
enabled: false,
text: "",
});
setDialogReward({
open: true,
new: true,
reward: {
id: "",
enabled: true,
name: "",
description: "",
image: "",
price: 0,
cooldown: 0,
},
});
}}
>
<PlusIcon /> {t("pages.loyalty-rewards.create-reward")}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t("pages.loyalty-rewards.reward-filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
<RewardList>
{rewards && rewards.length > 0 ? (
rewards
?.filter(
(r) =>
r.name.toLowerCase().includes(filterLC) ||
r.id.toLowerCase().includes(filterLC) ||
r.description.toLowerCase().includes(filterLC),
)
.map((r) => (
<RewardItem
key={r.id}
name={r.id}
item={r}
currency={(config?.currency || t("pages.loyalty-queue.points")).toLowerCase()}
onEdit={() =>
setDialogReward({
open: true,
new: false,
reward: r,
})
}
onDelete={() => deleteReward(r.id)}
onToggle={() => toggleReward(r.id)}
/>
))
) : (
<NoneText>{t("pages.loyalty-rewards.no-rewards")}</NoneText>
)}
</RewardList>
</>
);
}

View File

@ -1,71 +1,71 @@
import { styled } from '~/ui/theme';
import { styled } from "~/ui/theme";
export const RewardItemContainer = styled('article', {
backgroundColor: '$gray2',
margin: '0.5rem 0',
padding: '0.5rem',
borderLeft: '5px solid $teal8',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
transition: 'all 50ms',
'&:hover': {
backgroundColor: '$gray3',
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: '$red6',
backgroundColor: '$gray3',
color: '$gray10',
},
},
},
export const RewardItemContainer = styled("article", {
backgroundColor: "$gray2",
margin: "0.5rem 0",
padding: "0.5rem",
borderLeft: "5px solid $teal8",
borderRadius: "0.25rem",
borderBottom: "1px solid $gray4",
transition: "all 50ms",
"&:hover": {
backgroundColor: "$gray3",
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: "$red6",
backgroundColor: "$gray3",
color: "$gray10",
},
},
},
});
export const RewardHeader = styled('header', {
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
marginBottom: '0.4rem',
export const RewardHeader = styled("header", {
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.4rem",
});
export const RewardName = styled('span', {
color: '$gray12',
flex: 1,
fontWeight: 'bold',
variants: {
status: {
enabled: {},
disabled: {
color: '$gray9',
},
},
},
export const RewardName = styled("span", {
color: "$gray12",
flex: 1,
fontWeight: "bold",
variants: {
status: {
enabled: {},
disabled: {
color: "$gray9",
},
},
},
});
export const RewardDescription = styled('span', {
flex: 1,
fontSize: '0.9rem',
color: '$gray11',
export const RewardDescription = styled("span", {
flex: 1,
fontSize: "0.9rem",
color: "$gray11",
});
export const RewardActions = styled('div', {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
export const RewardActions = styled("div", {
display: "flex",
alignItems: "center",
gap: "0.25rem",
});
export const RewardID = styled('code', {
fontFamily: 'Space Mono',
color: '$teal11',
export const RewardID = styled("code", {
fontFamily: "Space Mono",
color: "$teal11",
});
export const RewardCost = styled('div', {
fontSize: '0.9rem',
marginRight: '0.5rem',
export const RewardCost = styled("div", {
fontSize: "0.9rem",
marginRight: "0.5rem",
});
export const RewardIcon = styled('div', {
width: '32px',
height: '32px',
backgroundColor: '$gray4',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
export const RewardIcon = styled("div", {
width: "32px",
height: "32px",
backgroundColor: "$gray4",
borderRadius: "0.25rem",
display: "flex",
alignItems: "center",
});

View File

@ -1,170 +1,168 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { RootState } from '~/store';
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "~/store";
import {
Button,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
styled,
Textarea,
} from '../../theme';
Button,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
styled,
Textarea,
} from "../../theme";
const Disclaimer = styled('div', {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
flex: '1',
height: '100vh',
const Disclaimer = styled("div", {
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
flex: "1",
height: "100vh",
});
const DisclaimerTitle = styled('h1', {
margin: 0,
const DisclaimerTitle = styled("h1", {
margin: 0,
});
const DisclaimerParagraph = styled('p', {
margin: '2rem 1rem',
const DisclaimerParagraph = styled("p", {
margin: "2rem 1rem",
});
export default function DebugPage(): React.ReactElement {
const { t } = useTranslation();
const [warningDismissed, setWarningDismissed] = useState(false);
const [readKey, setReadKey] = useState('');
const [readValue, setReadValue] = useState('');
const [writeKey, setWriteKey] = useState('');
const [writeValue, setWriteValue] = useState('');
const [writeErrorMsg, setWriteErrorMsg] = useState<string>(null);
const api = useSelector((state: RootState) => state.api.client);
const { t } = useTranslation();
const [warningDismissed, setWarningDismissed] = useState(false);
const [readKey, setReadKey] = useState("");
const [readValue, setReadValue] = useState("");
const [writeKey, setWriteKey] = useState("");
const [writeValue, setWriteValue] = useState("");
const [writeErrorMsg, setWriteErrorMsg] = useState<string>(null);
const api = useAppSelector((state) => state.api.client);
const performRead = async () => {
const value = await api.getKey(readKey);
setReadValue(value);
};
const performWrite = async () => {
await api.putKey(writeKey, writeValue);
};
const fixJSON = () => {
try {
setWriteValue(JSON.stringify(JSON.parse(writeValue)));
setWriteErrorMsg(null);
} catch (e: unknown) {
if (e instanceof Error) {
setWriteErrorMsg(e.message);
}
}
};
const dumpKeys = async () => {
console.log(await api.keyList());
};
const dumpAll = async () => {
console.log(await api.getKeysByPrefix(''));
};
const performRead = async () => {
const value = await api.getKey(readKey);
setReadValue(value);
};
const performWrite = async () => {
await api.putKey(writeKey, writeValue);
};
const fixJSON = () => {
try {
setWriteValue(JSON.stringify(JSON.parse(writeValue)));
setWriteErrorMsg(null);
} catch (e: unknown) {
if (e instanceof Error) {
setWriteErrorMsg(e.message);
}
}
};
const dumpKeys = async () => {
console.log(await api.keyList());
};
const dumpAll = async () => {
console.log(await api.getKeysByPrefix(""));
};
if (!warningDismissed) {
return (
<Disclaimer>
<DisclaimerTitle>{t('pages.debug.disclaimer-header')}</DisclaimerTitle>
<DisclaimerParagraph>
{t('pages.debug.big-ass-warning')}
</DisclaimerParagraph>
<Button variation="primary" onClick={() => setWarningDismissed(true)}>
{t('pages.debug.dismiss-warning')}
</Button>
</Disclaimer>
);
}
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.debug.title')}</PageTitle>
</PageHeader>
<Field size="fullWidth">
<Label htmlFor="read-key">{t('pages.debug.console-ops')}</Label>
<FlexRow align="left" spacing="1">
<Button
type="button"
onClick={() => {
void dumpKeys();
}}
>
{t('pages.debug.dump-keys')}
</Button>
<Button
type="button"
onClick={() => {
void dumpAll();
}}
>
{t('pages.debug.dump-all')}
</Button>
</FlexRow>
</Field>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void performRead();
}
}}
>
<Field size="fullWidth">
<Label htmlFor="read-key">{t('pages.debug.read-key')}</Label>
<FlexRow spacing="1">
<InputBox
required
value={readKey ?? ''}
onChange={(e) => setReadKey(e.target.value)}
id="read-key"
css={{ flex: '1' }}
/>
<Button type="submit">{t('form-actions.submit')}</Button>
</FlexRow>
<Textarea value={readValue ?? ''} readOnly>
{readValue ?? ''}
</Textarea>
</Field>
</form>
if (!warningDismissed) {
return (
<Disclaimer>
<DisclaimerTitle>{t("pages.debug.disclaimer-header")}</DisclaimerTitle>
<DisclaimerParagraph>{t("pages.debug.big-ass-warning")}</DisclaimerParagraph>
<Button variation="primary" onClick={() => setWarningDismissed(true)}>
{t("pages.debug.dismiss-warning")}
</Button>
</Disclaimer>
);
}
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.debug.title")}</PageTitle>
</PageHeader>
<Field size="fullWidth">
<Label htmlFor="read-key">{t("pages.debug.console-ops")}</Label>
<FlexRow align="left" spacing="1">
<Button
type="button"
onClick={() => {
void dumpKeys();
}}
>
{t("pages.debug.dump-keys")}
</Button>
<Button
type="button"
onClick={() => {
void dumpAll();
}}
>
{t("pages.debug.dump-all")}
</Button>
</FlexRow>
</Field>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void performRead();
}
}}
>
<Field size="fullWidth">
<Label htmlFor="read-key">{t("pages.debug.read-key")}</Label>
<FlexRow spacing="1">
<InputBox
required
value={readKey ?? ""}
onChange={(e) => setReadKey(e.target.value)}
id="read-key"
css={{ flex: "1" }}
/>
<Button type="submit">{t("form-actions.submit")}</Button>
</FlexRow>
<Textarea value={readValue ?? ""} readOnly>
{readValue ?? ""}
</Textarea>
</Field>
</form>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void performWrite();
}
}}
>
<Field size="fullWidth">
<Label htmlFor="write-key">{t('pages.debug.write-key')}</Label>
<FlexRow spacing={1}>
<InputBox
required
value={writeKey ?? ''}
onChange={(e) => setWriteKey(e.target.value)}
id="write-key"
css={{ flex: '1' }}
/>
<Button type="button" onClick={() => fixJSON()}>
{t('pages.debug.fix-json')}
</Button>
<Button type="submit">{t('form-actions.submit')}</Button>
</FlexRow>
<Textarea
required
value={writeValue ?? ''}
onChange={(e) => setWriteValue(e.target.value)}
>
{writeValue ?? ''}
</Textarea>
{writeErrorMsg && <FieldNote>{writeErrorMsg}</FieldNote>}
</Field>
</form>
</PageContainer>
);
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
void performWrite();
}
}}
>
<Field size="fullWidth">
<Label htmlFor="write-key">{t("pages.debug.write-key")}</Label>
<FlexRow spacing={1}>
<InputBox
required
value={writeKey ?? ""}
onChange={(e) => setWriteKey(e.target.value)}
id="write-key"
css={{ flex: "1" }}
/>
<Button type="button" onClick={() => fixJSON()}>
{t("pages.debug.fix-json")}
</Button>
<Button type="submit">{t("form-actions.submit")}</Button>
</FlexRow>
<Textarea
required
value={writeValue ?? ""}
onChange={(e) => setWriteValue(e.target.value)}
>
{writeValue ?? ""}
</Textarea>
{writeErrorMsg && <FieldNote>{writeErrorMsg}</FieldNote>}
</Field>
</form>
</PageContainer>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,140 +1,132 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import AlertContent from '../../components/AlertContent';
import RevealLink from '../../components/utils/RevealLink';
import SaveButton from '../../components/forms/SaveButton';
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useModule, useTimedStatus } from "~/lib/react";
import { useAppDispatch } from "~/store";
import apiReducer, { modules } from "~/store/api/reducer";
import AlertContent from "../../components/AlertContent";
import RevealLink from "../../components/utils/RevealLink";
import SaveButton from "../../components/forms/SaveButton";
import {
Field,
FieldNote,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
PasswordInputBox,
} from '../../theme';
import { Alert } from '../../theme/alert';
Field,
FieldNote,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
PasswordInputBox,
} from "../../theme";
import { Alert } from "../../theme/alert";
export default function ServerSettingsPage(): React.ReactElement {
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 [revealKVPassword, setRevealKVPassword] = useState(false);
const [showKilovoltWarning, setShowKilovoltWarning] = useState(false);
const [serverConfig, setServerConfig, loadStatus] = useModule(modules.httpConfig);
const { t } = useTranslation();
const dispatch = useAppDispatch();
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);
const insecureKilovolt = (serverConfig?.kv_password ?? '').length < 1;
const insecureKilovolt = (serverConfig?.kv_password ?? "").length < 1;
return (
<PageContainer>
<PageHeader css={{ paddingBottom: '1rem' }}>
<PageTitle>{t('pages.http.title')}</PageTitle>
</PageHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
if (insecureKilovolt) {
setShowKilovoltWarning(true);
return;
}
void dispatch(setServerConfig(serverConfig));
}}
>
<Field size="fullWidth">
<Label htmlFor="bind">{t('pages.http.bind')}</Label>
<InputBox
type="text"
id="bind"
placeholder={t('pages.http.bind-placeholder')}
value={serverConfig?.bind ?? ''}
disabled={busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
bind: e.target.value,
}),
)
}
/>
<FieldNote>{t('pages.http.bind-help')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="kvpassword">
{t('pages.http.kilovolt-password')}
<RevealLink value={revealKVPassword} setter={setRevealKVPassword} />
</Label>{' '}
<PasswordInputBox
reveal={revealKVPassword}
id="kvpassword"
placeholder={t('pages.http.kilovolt-placeholder')}
value={serverConfig?.kv_password ?? ''}
disabled={busy}
autoComplete="off"
onChange={(e) => {
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
kv_password: e.target.value,
}),
);
}}
/>
<FieldNote>{t('pages.http.kilovolt-placeholder')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="static">{t('pages.http.static-path')}</Label>
<InputBox
type="text"
id="static"
placeholder={t('pages.http.static-placeholder')}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
path: e.target.value,
}),
)
}
value={
serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''
}
/>
<FieldNote>
{t('pages.http.static-help', {
url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`,
})}
</FieldNote>
</Field>
<SaveButton type="submit" status={status} />
<Alert
defaultOpen={false}
open={showKilovoltWarning}
onOpenChange={setShowKilovoltWarning}
>
<AlertContent
variation="danger"
title={t('pages.http.kv-auth-warning.header')}
description={t('pages.http.kv-auth-warning.message')}
actionText={t('pages.http.kv-auth-warning.i-understand')}
actionButtonProps={{ variation: 'danger' }}
cancelText={t('pages.http.kv-auth-warning.go-back')}
showCancel={true}
onAction={() => {
void dispatch(setServerConfig(serverConfig));
}}
/>
</Alert>
</form>
</PageContainer>
);
return (
<PageContainer>
<PageHeader css={{ paddingBottom: "1rem" }}>
<PageTitle>{t("pages.http.title")}</PageTitle>
</PageHeader>
<form
onSubmit={(ev) => {
ev.preventDefault();
if (insecureKilovolt) {
setShowKilovoltWarning(true);
return;
}
void dispatch(setServerConfig(serverConfig));
}}
>
<Field size="fullWidth">
<Label htmlFor="bind">{t("pages.http.bind")}</Label>
<InputBox
type="text"
id="bind"
placeholder={t("pages.http.bind-placeholder")}
value={serverConfig?.bind ?? ""}
disabled={busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
bind: e.target.value,
}),
)
}
/>
<FieldNote>{t("pages.http.bind-help")}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="kvpassword">
{t("pages.http.kilovolt-password")}
<RevealLink value={revealKVPassword} setter={setRevealKVPassword} />
</Label>{" "}
<PasswordInputBox
reveal={revealKVPassword}
id="kvpassword"
placeholder={t("pages.http.kilovolt-placeholder")}
value={serverConfig?.kv_password ?? ""}
disabled={busy}
autoComplete="off"
onChange={(e) => {
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
kv_password: e.target.value,
}),
);
}}
/>
<FieldNote>{t("pages.http.kilovolt-placeholder")}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="static">{t("pages.http.static-path")}</Label>
<InputBox
type="text"
id="static"
placeholder={t("pages.http.static-placeholder")}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
path: e.target.value,
}),
)
}
value={serverConfig?.enable_static_server ? serverConfig?.path ?? "" : ""}
/>
<FieldNote>
{t("pages.http.static-help", {
url: `http://${serverConfig?.bind ?? "localhost:4337"}/static/`,
})}
</FieldNote>
</Field>
<SaveButton type="submit" status={status} />
<Alert defaultOpen={false} open={showKilovoltWarning} onOpenChange={setShowKilovoltWarning}>
<AlertContent
variation="danger"
title={t("pages.http.kv-auth-warning.header")}
description={t("pages.http.kv-auth-warning.message")}
actionText={t("pages.http.kv-auth-warning.i-understand")}
actionButtonProps={{ variation: "danger" }}
cancelText={t("pages.http.kv-auth-warning.go-back")}
showCancel={true}
onAction={() => {
void dispatch(setServerConfig(serverConfig));
}}
/>
</Alert>
</form>
</PageContainer>
);
}

View File

@ -1,37 +1,38 @@
import React, { useState } from 'react';
import { keyframes } from '@stitches/react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
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";
// @ts-expect-error Asset import
import logo from '~/assets/icon-logo.svg';
import logo from "~/assets/icon-logo.svg";
import { APPNAME, PageContainer, PageHeader, styled } from '../../theme';
import BrowserLink from '../../components/BrowserLink';
import Channels from '../../components/utils/Channels';
import { APPNAME, PageContainer, PageHeader, styled } from "../../theme";
import BrowserLink from "../../components/BrowserLink";
import Channels from "../../components/utils/Channels";
const gradientAnimation = keyframes({
'0%': {
backgroundPosition: '0% 50%',
},
'50%': {
backgroundPosition: '100% 50%',
},
'100%': {
backgroundPosition: '0% 50%',
},
"0%": {
backgroundPosition: "0% 50%",
},
"50%": {
backgroundPosition: "100% 50%",
},
"100%": {
backgroundPosition: "0% 50%",
},
});
const LogoPic = styled('div', {
minHeight: '170px',
width: '220px',
marginRight: '10px',
maskImage: `url(${logo as string})`,
maskRepeat: 'no-repeat',
maskPosition: 'center',
animation: `${gradientAnimation()} 12s ease infinite`,
backgroundSize: '400% 400%',
backgroundImage: `linear-gradient(
const LogoPic = styled("div", {
minHeight: "170px",
width: "220px",
marginRight: "10px",
maskImage: `url(${logo as string})`,
maskRepeat: "no-repeat",
maskPosition: "center",
animation: `${gradientAnimation()} 12s ease infinite`,
backgroundSize: "400% 400%",
backgroundImage: `linear-gradient(
45deg,
hsl(240deg 100% 20%) 0%,
hsl(289deg 100% 21%) 11%,
@ -46,116 +47,112 @@ const LogoPic = styled('div', {
)`,
});
const LogoName = styled('h1', {
fontSize: '40pt',
fontWeight: 200,
textAlign: 'left',
'@medium': {
fontSize: '60pt',
},
paddingBottom: '0.5rem',
const LogoName = styled("h1", {
fontSize: "40pt",
fontWeight: 200,
textAlign: "left",
"@medium": {
fontSize: "60pt",
},
paddingBottom: "0.5rem",
});
const Section = styled('section', {});
const SectionHeader = styled('h2', {});
const SectionParagraph = styled('p', {
lineHeight: '1.5',
paddingBottom: '1rem',
const Section = styled("section", {});
const SectionHeader = styled("h2", {});
const SectionParagraph = styled("p", {
lineHeight: "1.5",
paddingBottom: "1rem",
});
export const ChannelList = styled('ul', {
listStyle: 'none',
padding: 0,
margin: 0,
export const ChannelList = styled("ul", {
listStyle: "none",
padding: 0,
margin: 0,
});
export const Channel = styled('li', {
marginBottom: '1rem',
fontSize: '1rem',
export const Channel = styled("li", {
marginBottom: "1rem",
fontSize: "1rem",
});
export const ChannelLink = styled(BrowserLink, {
textDecoration: 'none',
color: '$teal11',
display: 'inline-flex',
flexDirection: 'row',
gap: '0.5rem',
alignItems: 'center',
'&:hover': {
textDecoration: 'underline',
},
textDecoration: "none",
color: "$teal11",
display: "inline-flex",
flexDirection: "row",
gap: "0.5rem",
alignItems: "center",
"&:hover": {
textDecoration: "underline",
},
});
export default function StrimertulPage(): React.ReactElement {
const navigate = useNavigate();
const { t } = useTranslation();
const [debugCount, setDebugCount] = useState(0);
const countForDebug = () => {
if (debugCount < 5) {
setDebugCount(debugCount + 1);
} else {
navigate('/debug');
}
};
const navigate = useNavigate();
const { t } = useTranslation();
const [debugCount, setDebugCount] = useState(0);
const countForDebug = () => {
if (debugCount < 5) {
setDebugCount(debugCount + 1);
} else {
navigate("/debug");
}
};
return (
<PageContainer>
<PageHeader
css={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
'@medium': {
flexDirection: 'row',
},
}}
>
<LogoPic
style={{
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
}}
onClick={countForDebug}
/>
<LogoName>{APPNAME}</LogoName>
</PageHeader>
<Section>
<SectionHeader>{t('pages.strimertul.need-help')}</SectionHeader>
<SectionParagraph css={{ paddingBottom: 0 }}>
{t('pages.strimertul.need-help-p1')}
</SectionParagraph>
{Channels}
</Section>
<Section>
<SectionHeader>{t('pages.strimertul.credits-header')}</SectionHeader>
<SectionParagraph>
<Trans
t={t}
i18nKey="pages.strimertul.credits-renko"
components={{
artist: (
<BrowserLink href="https://twitter.com/Sonic__Chan">
Sonic_Chan
</BrowserLink>
),
}}
/>
</SectionParagraph>
</Section>
<Section>
<SectionHeader>{t('pages.strimertul.license-header')}</SectionHeader>
<SectionParagraph>
<Trans
t={t}
i18nKey="pages.strimertul.license-notice-strimertul"
components={{
license: (
<BrowserLink href="https://git.sr.ht/~ashkeel/strimertul/tree/master/item/LICENSE">
GNU Affero General Public License v3.0
</BrowserLink>
),
}}
/>
</SectionParagraph>
</Section>
</PageContainer>
);
return (
<PageContainer>
<PageHeader
css={{
display: "flex",
alignItems: "center",
flexDirection: "column",
justifyContent: "center",
"@medium": {
flexDirection: "row",
},
}}
>
<LogoPic
style={{
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
}}
onClick={countForDebug}
/>
<LogoName>{APPNAME}</LogoName>
</PageHeader>
<Section>
<SectionHeader>{t("pages.strimertul.need-help")}</SectionHeader>
<SectionParagraph css={{ paddingBottom: 0 }}>
{t("pages.strimertul.need-help-p1")}
</SectionParagraph>
{Channels}
</Section>
<Section>
<SectionHeader>{t("pages.strimertul.credits-header")}</SectionHeader>
<SectionParagraph>
<Trans
t={t}
i18nKey="pages.strimertul.credits-renko"
components={{
artist: <BrowserLink href="https://twitter.com/Sonic__Chan">Sonic_Chan</BrowserLink>,
}}
/>
</SectionParagraph>
</Section>
<Section>
<SectionHeader>{t("pages.strimertul.license-header")}</SectionHeader>
<SectionParagraph>
<Trans
t={t}
i18nKey="pages.strimertul.license-notice-strimertul"
components={{
license: (
<BrowserLink href="https://git.sr.ht/~ashkeel/strimertul/tree/master/item/LICENSE">
GNU Affero General Public License v3.0
</BrowserLink>
),
}}
/>
</SectionParagraph>
</Section>
</PageContainer>
);
}

View File

@ -1,97 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { languages } from '~/locale/languages';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import RadioGroup from '../../components/forms/RadioGroup';
import type React from "react";
import { useTranslation } from "react-i18next";
import { useModule } from "~/lib/react";
import { languages } from "~/locale/languages";
import { useAppDispatch } from "~/store";
import { modules } from "~/store/api/reducer";
import RadioGroup from "../../components/forms/RadioGroup";
import {
Button,
Field,
Label,
PageContainer,
PageHeader,
PageTitle,
styled,
themes,
} from '../../theme';
Button,
Field,
Label,
PageContainer,
PageHeader,
PageTitle,
styled,
themes,
} from "../../theme";
const PartialWarning = styled('small', {
color: '$yellow11',
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);
const [t, i18n] = useTranslation();
const dispatch = useAppDispatch();
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const [t, i18n] = useTranslation();
const dispatch = useAppDispatch();
return (
<PageContainer>
<PageHeader css={{ paddingBottom: '1rem' }}>
<PageTitle>{t('pages.uiconfig.title')}</PageTitle>
</PageHeader>
<Field size="fullWidth">
<Label>{t('pages.uiconfig.language')}</Label>
<RadioGroup
aria-label={t('pages.uiconfig.language')}
defaultValue={i18n.resolvedLanguage}
value={uiConfig?.language ?? i18n.resolvedLanguage}
onValueChange={(value) => {
void dispatch(setUiConfig({ ...uiConfig, language: value }));
localStorage.setItem('language', value);
}}
values={languages.map((lang) => ({
id: lang.code,
label: (
<span>
{lang.name}{' '}
{lang.keys < maxKeys ? (
<PartialWarning>
{t('pages.uiconfig.partial-translation')} (
{((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/
{maxKeys})
</PartialWarning>
) : null}
</span>
),
}))}
/>
</Field>
<Field size="fullWidth">
<Label>{t('pages.uiconfig.theme')}</Label>
<RadioGroup
aria-label={t('pages.uiconfig.theme')}
defaultValue="dark"
value={uiConfig?.theme ?? 'dark'}
onValueChange={(value) => {
void dispatch(setUiConfig({ ...uiConfig, theme: value }));
localStorage.setItem('theme', value);
}}
values={themes.map((theme) => ({
id: theme,
label: t(`pages.uiconfig.themes.${theme}`),
}))}
/>
</Field>
<Button
type="button"
onClick={() => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingDone: false,
onboardingStatus: 0,
}),
);
}}
>
{t('pages.uiconfig.repeat-onboarding')}
</Button>
</PageContainer>
);
return (
<PageContainer>
<PageHeader css={{ paddingBottom: "1rem" }}>
<PageTitle>{t("pages.uiconfig.title")}</PageTitle>
</PageHeader>
<Field size="fullWidth">
<Label>{t("pages.uiconfig.language")}</Label>
<RadioGroup
aria-label={t("pages.uiconfig.language")}
defaultValue={i18n.resolvedLanguage}
value={uiConfig?.language ?? i18n.resolvedLanguage}
onValueChange={(value) => {
void dispatch(setUiConfig({ ...uiConfig, language: value }));
localStorage.setItem("language", value);
}}
values={languages.map((lang) => ({
id: lang.code,
label: (
<span>
{lang.name}{" "}
{lang.keys < maxKeys ? (
<PartialWarning>
{t("pages.uiconfig.partial-translation")} (
{((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/{maxKeys})
</PartialWarning>
) : null}
</span>
),
}))}
/>
</Field>
<Field size="fullWidth">
<Label>{t("pages.uiconfig.theme")}</Label>
<RadioGroup
aria-label={t("pages.uiconfig.theme")}
defaultValue="dark"
value={uiConfig?.theme ?? "dark"}
onValueChange={(value) => {
void dispatch(setUiConfig({ ...uiConfig, theme: value }));
localStorage.setItem("theme", value);
}}
values={themes.map((theme) => ({
id: theme,
label: t(`pages.uiconfig.themes.${theme}`),
}))}
/>
</Field>
<Button
type="button"
onClick={() => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingDone: false,
onboardingStatus: 0,
}),
);
}}
>
{t("pages.uiconfig.repeat-onboarding")}
</Button>
</PageContainer>
);
}

View File

@ -1,297 +1,275 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '@radix-ui/react-icons';
import { useModule, useStatus } from '~/lib/react';
import apiReducer, { modules } from '~/store/api/reducer';
import { useAppDispatch } from '~/store';
import MultiInput from '../../components/forms/MultiInput';
import type React from "react";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@radix-ui/react-icons";
import { useModule, useTimedStatus } from "~/lib/react";
import apiReducer, { modules } from "~/store/api/reducer";
import { useAppDispatch } from "~/store";
import MultiInput from "../../components/forms/MultiInput";
import {
Checkbox,
CheckboxIndicator,
Field,
FlexRow,
Label,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../../theme';
import SaveButton from '../../components/forms/SaveButton';
Checkbox,
CheckboxIndicator,
Field,
FlexRow,
Label,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from "../../theme";
import SaveButton from "../../components/forms/SaveButton";
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 { t } = useTranslation();
const dispatch = useAppDispatch();
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchChatAlerts);
const status = useTimedStatus(loadStatus.save);
return (
<PageContainer>
<form
onSubmit={(ev) => {
void dispatch(setAlerts(alerts));
ev.preventDefault();
}}
>
<PageHeader>
<PageTitle>{t('pages.alerts.title')}</PageTitle>
<TextBlock>{t('pages.alerts.desc')}</TextBlock>
</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="raid">{t('pages.alerts.events.raid')}</TabButton>
<TabButton value="cheer">
{t('pages.alerts.events.cheer')}
</TabButton>
</TabList>
<TabContent value="follow">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.follow?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: {
...alerts.follow,
enabled: !!ev,
},
}),
);
}}
id="follow-enabled"
>
<CheckboxIndicator>
{alerts?.follow?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
return (
<PageContainer>
<form
onSubmit={(ev) => {
void dispatch(setAlerts(alerts));
ev.preventDefault();
}}
>
<PageHeader>
<PageTitle>{t("pages.alerts.title")}</PageTitle>
<TextBlock>{t("pages.alerts.desc")}</TextBlock>
</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="raid">{t("pages.alerts.events.raid")}</TabButton>
<TabButton value="cheer">{t("pages.alerts.events.cheer")}</TabButton>
</TabList>
<TabContent value="follow">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.follow?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: {
...alerts.follow,
enabled: !!ev,
},
}),
);
}}
id="follow-enabled"
>
<CheckboxIndicator>{alerts?.follow?.enabled && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="follow-enabled">
{t('pages.alerts.follow-enable')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t('pages.alerts.messages')}</Label>
<small>{t('pages.alerts.msg-info')}</small>
<MultiInput
value={alerts?.follow?.messages ?? ['']}
disabled={!alerts?.follow?.enabled ?? true}
required={alerts?.follow?.enabled ?? false}
onChange={(messages) => {
dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: { ...alerts.follow, messages },
}),
);
}}
/>
</Field>
</TabContent>
<TabContent value="sub">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.subscription?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: {
...alerts.subscription,
enabled: !!ev,
},
}),
);
}}
id="subscription-enabled"
>
<CheckboxIndicator>
{alerts?.subscription?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<Label htmlFor="follow-enabled">{t("pages.alerts.follow-enable")}</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t("pages.alerts.messages")}</Label>
<small>{t("pages.alerts.msg-info")}</small>
<MultiInput
value={alerts?.follow?.messages ?? [""]}
disabled={!alerts?.follow?.enabled ?? true}
required={alerts?.follow?.enabled ?? false}
onChange={(messages) => {
dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: { ...alerts.follow, messages },
}),
);
}}
/>
</Field>
</TabContent>
<TabContent value="sub">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.subscription?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: {
...alerts.subscription,
enabled: !!ev,
},
}),
);
}}
id="subscription-enabled"
>
<CheckboxIndicator>
{alerts?.subscription?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<Label htmlFor="subscription-enabled">
{t('pages.alerts.subscription-enable')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t('pages.alerts.messages')}</Label>
<small>{t('pages.alerts.msg-info')}</small>
<MultiInput
value={alerts?.subscription?.messages ?? ['']}
disabled={!alerts?.subscription?.enabled ?? true}
required={alerts?.subscription?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: { ...alerts.subscription, messages },
}),
);
}}
/>
</Field>
</TabContent>
<Label htmlFor="subscription-enabled">
{t("pages.alerts.subscription-enable")}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t("pages.alerts.messages")}</Label>
<small>{t("pages.alerts.msg-info")}</small>
<MultiInput
value={alerts?.subscription?.messages ?? [""]}
disabled={!alerts?.subscription?.enabled ?? true}
required={alerts?.subscription?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: { ...alerts.subscription, messages },
}),
);
}}
/>
</Field>
</TabContent>
<TabContent value="gift">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.gift_sub?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: {
...alerts.gift_sub,
enabled: !!ev,
},
}),
);
}}
id="gift_sub-enabled"
>
<CheckboxIndicator>
{alerts?.gift_sub?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<TabContent value="gift">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.gift_sub?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: {
...alerts.gift_sub,
enabled: !!ev,
},
}),
);
}}
id="gift_sub-enabled"
>
<CheckboxIndicator>
{alerts?.gift_sub?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<Label htmlFor="gift_sub-enabled">
{t('pages.alerts.gift_sub-enable')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t('pages.alerts.messages')}</Label>
<small>{t('pages.alerts.msg-info')}</small>
<MultiInput
value={alerts?.gift_sub?.messages ?? ['']}
disabled={!alerts?.gift_sub?.enabled ?? true}
required={alerts?.gift_sub?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: { ...alerts.gift_sub, messages },
}),
);
}}
/>
</Field>
</TabContent>
<Label htmlFor="gift_sub-enabled">{t("pages.alerts.gift_sub-enable")}</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t("pages.alerts.messages")}</Label>
<small>{t("pages.alerts.msg-info")}</small>
<MultiInput
value={alerts?.gift_sub?.messages ?? [""]}
disabled={!alerts?.gift_sub?.enabled ?? true}
required={alerts?.gift_sub?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: { ...alerts.gift_sub, messages },
}),
);
}}
/>
</Field>
</TabContent>
<TabContent value="raid">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.raid?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: {
...alerts.raid,
enabled: !!ev,
},
}),
);
}}
id="raid-enabled"
>
<CheckboxIndicator>
{alerts?.raid?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<TabContent value="raid">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.raid?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: {
...alerts.raid,
enabled: !!ev,
},
}),
);
}}
id="raid-enabled"
>
<CheckboxIndicator>{alerts?.raid?.enabled && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="raid-enabled">
{t('pages.alerts.raid-enable')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t('pages.alerts.messages')}</Label>
<small>{t('pages.alerts.msg-info')}</small>
<MultiInput
value={alerts?.raid?.messages ?? ['']}
disabled={!alerts?.raid?.enabled ?? true}
required={alerts?.raid?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: { ...alerts.raid, messages },
}),
);
}}
/>
</Field>
</TabContent>
<Label htmlFor="raid-enabled">{t("pages.alerts.raid-enable")}</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t("pages.alerts.messages")}</Label>
<small>{t("pages.alerts.msg-info")}</small>
<MultiInput
value={alerts?.raid?.messages ?? [""]}
disabled={!alerts?.raid?.enabled ?? true}
required={alerts?.raid?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: { ...alerts.raid, messages },
}),
);
}}
/>
</Field>
</TabContent>
<TabContent value="cheer">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.cheer?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: {
...alerts.cheer,
enabled: !!ev,
},
}),
);
}}
id="raid-enabled"
>
<CheckboxIndicator>
{alerts?.cheer?.enabled && <CheckIcon />}
</CheckboxIndicator>
</Checkbox>
<TabContent value="cheer">
<Field size="fullWidth">
<FlexRow spacing={1} align="left">
<Checkbox
checked={alerts?.cheer?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: {
...alerts.cheer,
enabled: !!ev,
},
}),
);
}}
id="raid-enabled"
>
<CheckboxIndicator>{alerts?.cheer?.enabled && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="cheer-enabled">
{t('pages.alerts.cheer-enable')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t('pages.alerts.messages')}</Label>
<small>{t('pages.alerts.msg-info')}</small>
<MultiInput
value={alerts?.cheer?.messages ?? ['']}
disabled={!alerts?.cheer?.enabled ?? true}
required={alerts?.cheer?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: { ...alerts.cheer, messages },
}),
);
}}
/>
</Field>
</TabContent>
</TabContainer>
<SaveButton status={status} type="submit" />
</form>
</PageContainer>
);
<Label htmlFor="cheer-enabled">{t("pages.alerts.cheer-enable")}</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label>{t("pages.alerts.messages")}</Label>
<small>{t("pages.alerts.msg-info")}</small>
<MultiInput
value={alerts?.cheer?.messages ?? [""]}
disabled={!alerts?.cheer?.enabled ?? true}
required={alerts?.cheer?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: { ...alerts.cheer, messages },
}),
);
}}
/>
</Field>
</TabContent>
</TabContainer>
<SaveButton status={status} type="submit" />
</form>
</PageContainer>
);
}

View File

@ -1,507 +1,475 @@
import { PlusIcon } from '@radix-ui/react-icons';
import React, { 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 { PlusIcon } from "@radix-ui/react-icons";
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,
} from '~/store/api/types';
import { TestCommandTemplate } from '@wailsapp/go/main/App';
import AlertContent from '../../components/AlertContent';
import DialogContent from '../../components/DialogContent';
accessLevels,
type AccessLevelType,
type ReplyType,
type TwitchChatCustomCommand,
} from "~/store/api/types";
import { TestCommandTemplate } from "@wailsapp/go/main/App";
import AlertContent from "../../components/AlertContent";
import DialogContent from "../../components/DialogContent";
import {
Button,
ComboBox,
Dialog,
DialogActions,
DialogClose,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
MultiToggle,
MultiToggleItem,
NoneText,
PageContainer,
PageHeader,
PageTitle,
styled,
Textarea,
TextBlock,
} from '../../theme';
import { Alert, AlertTrigger } from '../../theme/alert';
Button,
ComboBox,
Dialog,
DialogActions,
DialogClose,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
MultiButton,
MultiToggle,
MultiToggleItem,
NoneText,
PageContainer,
PageHeader,
PageTitle,
styled,
Textarea,
TextBlock,
} from "../../theme";
import { Alert, AlertTrigger } from "../../theme/alert";
const CommandList = styled('div', { marginTop: '1rem' });
const CommandItemContainer = styled('article', {
backgroundColor: '$gray2',
margin: '0.5rem 0',
padding: '0.5rem',
borderLeft: '5px solid $teal8',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
transition: 'all 50ms',
'&:hover': {
backgroundColor: '$gray3',
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: '$red6',
backgroundColor: '$gray3',
color: '$gray10',
},
},
},
const CommandList = styled("div", { marginTop: "1rem" });
const CommandItemContainer = styled("article", {
backgroundColor: "$gray2",
margin: "0.5rem 0",
padding: "0.5rem",
borderLeft: "5px solid $teal8",
borderRadius: "0.25rem",
borderBottom: "1px solid $gray4",
transition: "all 50ms",
"&:hover": {
backgroundColor: "$gray3",
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: "$red6",
backgroundColor: "$gray3",
color: "$gray10",
},
},
},
});
const CommandHeader = styled('header', {
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
marginBottom: '0.4rem',
const CommandHeader = styled("header", {
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.4rem",
});
const CommandName = styled('span', {
color: '$teal10',
fontWeight: 'bold',
variants: {
status: {
enabled: {},
disabled: {
color: '$gray9',
},
},
},
const CommandName = styled("span", {
color: "$teal10",
fontWeight: "bold",
variants: {
status: {
enabled: {},
disabled: {
color: "$gray9",
},
},
},
});
const CommandDescription = styled('span', {
display: 'flex',
alignContent: 'baseline',
gap: '0.25rem',
flex: 1,
const CommandDescription = styled("span", {
display: "flex",
alignContent: "baseline",
gap: "0.25rem",
flex: 1,
});
const CommandActions = styled('div', {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
const CommandActions = styled("div", {
display: "flex",
alignItems: "center",
gap: "0.25rem",
});
const CommandText = styled('div', {
fontFamily: 'Space Mono',
fontSize: '10pt',
margin: '-0.5rem',
marginTop: '0',
padding: '0.5rem',
backgroundColor: '$gray4',
lineHeight: '1.2rem',
const CommandText = styled("div", {
fontFamily: "Space Mono",
fontSize: "10pt",
margin: "-0.5rem",
marginTop: "0",
padding: "0.5rem",
backgroundColor: "$gray4",
lineHeight: "1.2rem",
});
const CommandType = styled('div', {
fontFamily: 'Inter',
display: 'inline-flex',
backgroundColor: '$gray2',
fontSize: '8pt',
textTransform: 'uppercase',
alignItems: 'center',
padding: '0.2rem 0.3rem',
borderRadius: '0.4rem',
marginRight: '0.5rem',
height: '1.2rem',
variants: {
type: {
chat: {
display: 'none',
},
whisper: {
backgroundColor: '$amber4',
color: '$amber9',
},
announce: {
backgroundColor: '$crimson4',
color: '$crimson11',
},
reply: {
backgroundColor: '$gray2',
color: '$gray12',
},
},
},
const CommandType = styled("div", {
fontFamily: "Inter",
display: "inline-flex",
backgroundColor: "$gray2",
fontSize: "8pt",
textTransform: "uppercase",
alignItems: "center",
padding: "0.2rem 0.3rem",
borderRadius: "0.4rem",
marginRight: "0.5rem",
height: "1.2rem",
variants: {
type: {
chat: {
display: "none",
},
whisper: {
backgroundColor: "$amber4",
color: "$amber9",
},
announce: {
backgroundColor: "$crimson4",
color: "$crimson11",
},
reply: {
backgroundColor: "$gray2",
color: "$gray12",
},
},
},
});
const ACLIndicator = styled('span', {
fontFamily: 'Space Mono',
fontSize: '10pt',
marginRight: '0.5rem',
const ACLIndicator = styled("span", {
fontFamily: "Space Mono",
fontSize: "10pt",
marginRight: "0.5rem",
});
interface CommandItemProps {
name: string;
item: TwitchChatCustomCommand;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
name: string;
item: TwitchChatCustomCommand;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
function CommandItem({
name,
item,
onToggle,
onEdit,
onDelete,
name,
item,
onToggle,
onEdit,
onDelete,
}: CommandItemProps): React.ReactElement {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<CommandItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
<CommandHeader>
<CommandName status={item.enabled ? 'enabled' : 'disabled'}>
{name}
</CommandName>
<CommandDescription>{item.description}</CommandDescription>
<CommandActions>
{item.access_level !== 'everyone' && (
<ACLIndicator>
{t(`pages.botcommands.acl.${item.access_level}`)}
{item.access_level !== 'streamer' && '+'}
</ACLIndicator>
)}
<MultiButton>
<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)}
>
{t('form-actions.edit')}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t('form-actions.delete')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.botcommands.remove-command-title', { name })}
description={t('form-actions.warning-delete')}
actionText={t('form-actions.delete')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</CommandActions>
</CommandHeader>
<CommandText>
<CommandType type={item.response_type ?? 'chat'}>
{t(`pages.botcommands.response-types.${item.response_type}`)}
</CommandType>
{item.response}
</CommandText>
</CommandItemContainer>
);
return (
<CommandItemContainer status={item.enabled ? "enabled" : "disabled"}>
<CommandHeader>
<CommandName status={item.enabled ? "enabled" : "disabled"}>{name}</CommandName>
<CommandDescription>{item.description}</CommandDescription>
<CommandActions>
{item.access_level !== "everyone" && (
<ACLIndicator>
{t(`pages.botcommands.acl.${item.access_level}`)}
{item.access_level !== "streamer" && "+"}
</ACLIndicator>
)}
<MultiButton>
<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)}>
{t("form-actions.edit")}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t("form-actions.delete")}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t("pages.botcommands.remove-command-title", { name })}
description={t("form-actions.warning-delete")}
actionText={t("form-actions.delete")}
actionButtonProps={{ variation: "danger" }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</CommandActions>
</CommandHeader>
<CommandText>
<CommandType type={item.response_type ?? "chat"}>
{t(`pages.botcommands.response-types.${item.response_type}`)}
</CommandType>
{item.response}
</CommandText>
</CommandItemContainer>
);
}
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchChatCustomCommand };
type DialogPrompt = { kind: "new" } | { kind: "edit"; name: string; item: TwitchChatCustomCommand };
function CommandDialog({
kind,
name,
item,
onSubmit,
kind,
name,
item,
onSubmit,
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchChatCustomCommand;
onSubmit?: (name: string, item: TwitchChatCustomCommand) => void;
kind: "new" | "edit";
name?: string;
item?: TwitchChatCustomCommand;
onSubmit?: (name: string, item: TwitchChatCustomCommand) => void;
}) {
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 [response, setResponse] = useState(item?.response ?? '');
const responseRef = useRef<HTMLTextAreaElement>(null);
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'];
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 [response, setResponse] = useState(item?.response ?? "");
const responseRef = useRef<HTMLTextAreaElement>(null);
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}
>
<form
onSubmit={(e) => {
if (!(e.target as HTMLFormElement).checkValidity()) {
return false;
}
e.preventDefault();
void (async () => {
try {
await TestCommandTemplate(response);
if (onSubmit) {
onSubmit(commandName, {
...item,
description,
response,
response_type: responseType,
access_level: accessLevel,
});
}
} catch (error: unknown) {
setResponseError(error as string);
responseRef.current?.setCustomValidity(
t('pages.botcommands.command-invalid-format'),
);
}
})();
}}
>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-name">
{t('pages.botcommands.command-name')}
</Label>
<InputBox
id="command-name"
value={commandName}
required={true}
onChange={(e) => {
setCommandName(e.target.value);
// If command name is different but matches another defined command, set as invalid
if (e.target.value !== name && e.target.value in commands) {
(e.target as HTMLInputElement).setCustomValidity(
t('pages.botcommands.command-already-in-use'),
);
} else {
(e.target as HTMLInputElement).setCustomValidity('');
}
}}
placeholder={t('pages.botcommands.command-name-placeholder')}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-description">
{t('pages.botcommands.command-desc')}
</Label>
<InputBox
id="command-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('pages.botcommands.command-desc-placeholder')}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-response">
{t('pages.botcommands.command-response')}
<MultiToggle
css={{ marginLeft: '0.5rem' }}
value={responseType}
type="single"
onValueChange={(newType) => {
setResponseType(newType as ReplyType);
}}
>
{replyTypes.map((replyType) => (
<MultiToggleItem size="small" key={replyType} value={replyType}>
{t(`pages.botcommands.response-types.${replyType}`)}
</MultiToggleItem>
))}
</MultiToggle>
</Label>
<Textarea
value={response}
required={true}
onChange={(e) => {
responseRef.current?.setCustomValidity('');
setResponse(e.target.value);
}}
id="command-response"
ref={responseRef}
placeholder={t('pages.botcommands.command-response-placeholder')}
>
{item?.response}
</Textarea>
{responseError && (
<FieldNote
css={{
color: '$red10',
}}
>
{responseError}
</FieldNote>
)}
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-acl">
{t('pages.botcommands.command-acl')}
</Label>
<ComboBox
id="command-acl"
value={accessLevel}
onChange={(e) => setAccessLevel(e.target.value as AccessLevelType)}
>
{accessLevels.map((level) => (
<option key={level} value={level}>
{t(`pages.botcommands.acl.${level}`)}
</option>
))}
</ComboBox>
<FieldNote>{t('pages.botcommands.command-acl-help')}</FieldNote>
</Field>
<DialogActions>
<Button variation="primary">
{kind === 'new' ? t('form-actions.create') : t('form-actions.edit')}
</Button>
<DialogClose asChild>
<Button type="button">{t('form-actions.cancel')}</Button>
</DialogClose>
</DialogActions>
</form>
</DialogContent>
);
return (
<DialogContent title={t(`pages.botcommands.command-header-${kind}`)} closeButton={true}>
<form
onSubmit={(e) => {
if (!(e.target as HTMLFormElement).checkValidity()) {
return false;
}
e.preventDefault();
void (async () => {
try {
await TestCommandTemplate(response);
if (onSubmit) {
onSubmit(commandName, {
...item,
description,
response,
response_type: responseType,
access_level: accessLevel,
});
}
} catch (error: unknown) {
setResponseError(error as string);
responseRef.current?.setCustomValidity(t("pages.botcommands.command-invalid-format"));
}
})();
}}
>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-name">{t("pages.botcommands.command-name")}</Label>
<InputBox
id="command-name"
value={commandName}
required={true}
onChange={(e) => {
setCommandName(e.target.value);
// If command name is different but matches another defined command, set as invalid
if (e.target.value !== name && e.target.value in commands) {
(e.target as HTMLInputElement).setCustomValidity(
t("pages.botcommands.command-already-in-use"),
);
} else {
(e.target as HTMLInputElement).setCustomValidity("");
}
}}
placeholder={t("pages.botcommands.command-name-placeholder")}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-description">{t("pages.botcommands.command-desc")}</Label>
<InputBox
id="command-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("pages.botcommands.command-desc-placeholder")}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-response">
{t("pages.botcommands.command-response")}
<MultiToggle
css={{ marginLeft: "0.5rem" }}
value={responseType}
type="single"
onValueChange={(newType) => {
setResponseType(newType as ReplyType);
}}
>
{replyTypes.map((replyType) => (
<MultiToggleItem size="small" key={replyType} value={replyType}>
{t(`pages.botcommands.response-types.${replyType}`)}
</MultiToggleItem>
))}
</MultiToggle>
</Label>
<Textarea
value={response}
required={true}
onChange={(e) => {
responseRef.current?.setCustomValidity("");
setResponse(e.target.value);
}}
id="command-response"
ref={responseRef}
placeholder={t("pages.botcommands.command-response-placeholder")}
>
{item?.response}
</Textarea>
{responseError && (
<FieldNote
css={{
color: "$red10",
}}
>
{responseError}
</FieldNote>
)}
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="command-acl">{t("pages.botcommands.command-acl")}</Label>
<ComboBox
id="command-acl"
value={accessLevel}
onChange={(e) => setAccessLevel(e.target.value as AccessLevelType)}
>
{accessLevels.map((level) => (
<option key={level} value={level}>
{t(`pages.botcommands.acl.${level}`)}
</option>
))}
</ComboBox>
<FieldNote>{t("pages.botcommands.command-acl-help")}</FieldNote>
</Field>
<DialogActions>
<Button variation="primary">
{kind === "new" ? t("form-actions.create") : t("form-actions.edit")}
</Button>
<DialogClose asChild>
<Button type="button">{t("form-actions.cancel")}</Button>
</DialogClose>
</DialogActions>
</form>
</DialogContent>
);
}
export default function TwitchChatCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchChatCommands);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [commands, setCommands] = useModule(modules.twitchChatCommands);
const [filter, setFilter] = useState("");
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const filterLC = filter.toLowerCase();
const filterLC = filter.toLowerCase();
const setCommand = (newName: string, data: TwitchChatCustomCommand): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(
setCommands({
...commands,
[newName]: {
...data,
enabled: true,
},
}),
);
break;
case 'edit': {
const oldName = activeDialog.name;
void dispatch(
setCommands({
...commands,
[oldName]: undefined,
[newName]: data,
}),
);
break;
}
}
setActiveDialog(null);
};
const setCommand = (newName: string, data: TwitchChatCustomCommand): void => {
switch (activeDialog.kind) {
case "new":
void dispatch(
setCommands({
...commands,
[newName]: {
...data,
enabled: true,
},
}),
);
break;
case "edit": {
const oldName = activeDialog.name;
void dispatch(
setCommands({
...commands,
[oldName]: undefined,
[newName]: data,
}),
);
break;
}
}
setActiveDialog(null);
};
const deleteCommand = (cmd: string): void => {
void dispatch(
setCommands({
...commands,
[cmd]: undefined,
}),
);
};
const deleteCommand = (cmd: string): void => {
void dispatch(
setCommands({
...commands,
[cmd]: undefined,
}),
);
};
const toggleCommand = (cmd: string): void => {
void dispatch(
setCommands({
...commands,
[cmd]: {
...commands[cmd],
enabled: !commands[cmd].enabled,
},
}),
);
};
const toggleCommand = (cmd: string): void => {
void dispatch(
setCommands({
...commands,
[cmd]: {
...commands[cmd],
enabled: !commands[cmd].enabled,
},
}),
);
};
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.botcommands.title')}</PageTitle>
<TextBlock>{t('pages.botcommands.desc')}</TextBlock>
</PageHeader>
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.botcommands.title")}</PageTitle>
<TextBlock>{t("pages.botcommands.desc")}</TextBlock>
</PageHeader>
<FlexRow spacing="1" align="left">
<Button
variation="primary"
onClick={() => setActiveDialog({ kind: 'new' })}
>
<PlusIcon /> {t('pages.botcommands.add-button')}
</Button>
<FlexRow spacing="1" align="left">
<Button variation="primary" onClick={() => setActiveDialog({ kind: "new" })}>
<PlusIcon /> {t("pages.botcommands.add-button")}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.botcommands.search-placeholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
<CommandList>
{commands ? (
Object.keys(commands ?? {})
?.filter(
(cmd) =>
cmd.toLowerCase().includes(filterLC) ||
commands[cmd].description.toLowerCase().includes(filterLC),
)
.sort()
.map((cmd) => (
<CommandItem
key={cmd}
name={cmd}
item={commands[cmd]}
onToggle={() => toggleCommand(cmd)}
onEdit={() =>
setActiveDialog({
kind: 'edit',
name: cmd,
item: commands[cmd],
})
}
onDelete={() => deleteCommand(cmd)}
/>
))
) : (
<NoneText>{t('pages.botcommands.no-commands')}</NoneText>
)}
</CommandList>
<InputBox
css={{ flex: 1 }}
placeholder={t("pages.botcommands.search-placeholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
<CommandList>
{commands ? (
Object.keys(commands ?? {})
?.filter(
(cmd) =>
cmd.toLowerCase().includes(filterLC) ||
commands[cmd].description.toLowerCase().includes(filterLC),
)
.sort()
.map((cmd) => (
<CommandItem
key={cmd}
name={cmd}
item={commands[cmd]}
onToggle={() => toggleCommand(cmd)}
onEdit={() =>
setActiveDialog({
kind: "edit",
name: cmd,
item: commands[cmd],
})
}
onDelete={() => deleteCommand(cmd)}
/>
))
) : (
<NoneText>{t("pages.botcommands.no-commands")}</NoneText>
)}
</CommandList>
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog && (
<CommandDialog
{...activeDialog}
onSubmit={(name, data) => setCommand(name, data)}
/>
)}
</Dialog>
</PageContainer>
);
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog && (
<CommandDialog {...activeDialog} onSubmit={(name, data) => setCommand(name, data)} />
)}
</Dialog>
</PageContainer>
);
}

View File

@ -1,447 +1,412 @@
import { PlusIcon } from '@radix-ui/react-icons';
import { TFunction } from 'i18next';
import React, { 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 AlertContent from '../../components/AlertContent';
import DialogContent from '../../components/DialogContent';
import Interval from '../../components/forms/Interval';
import { hours, minutes } from '../../components/forms/units';
import MultiInput from '../../components/forms/MultiInput';
import { PlusIcon } from "@radix-ui/react-icons";
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 type { TwitchChatTimer } from "~/store/api/types";
import AlertContent from "../../components/AlertContent";
import DialogContent from "../../components/DialogContent";
import Interval from "../../components/forms/Interval";
import { hours, minutes } from "../../components/forms/units";
import MultiInput from "../../components/forms/MultiInput";
import {
Button,
Dialog,
DialogActions,
DialogClose,
Field,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
PageContainer,
PageHeader,
PageTitle,
styled,
TextBlock,
} from '../../theme';
import { Alert, AlertTrigger } from '../../theme/alert';
Button,
Dialog,
DialogActions,
DialogClose,
Field,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
PageContainer,
PageHeader,
PageTitle,
styled,
TextBlock,
} from "../../theme";
import { Alert, AlertTrigger } from "../../theme/alert";
const TimerList = styled('div', { marginTop: '1rem' });
const TimerItemContainer = styled('article', {
backgroundColor: '$gray3',
margin: '0.5rem 0',
padding: '0.5rem',
borderLeft: '5px solid $teal8',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
transition: 'all 50ms',
'&:hover': {
backgroundColor: '$gray3',
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: '$red7',
backgroundColor: '$gray3',
color: '$gray10',
},
},
},
const TimerList = styled("div", { marginTop: "1rem" });
const TimerItemContainer = styled("article", {
backgroundColor: "$gray3",
margin: "0.5rem 0",
padding: "0.5rem",
borderLeft: "5px solid $teal8",
borderRadius: "0.25rem",
borderBottom: "1px solid $gray4",
transition: "all 50ms",
"&:hover": {
backgroundColor: "$gray3",
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: "$red7",
backgroundColor: "$gray3",
color: "$gray10",
},
},
},
});
const TimerHeader = styled('header', {
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
marginBottom: '0.4rem',
const TimerHeader = styled("header", {
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.4rem",
});
const TimerName = styled('span', {
color: '$teal10',
fontWeight: 'bold',
variants: {
status: {
enabled: {},
disabled: {
color: '$gray10',
},
},
},
const TimerName = styled("span", {
color: "$teal10",
fontWeight: "bold",
variants: {
status: {
enabled: {},
disabled: {
color: "$gray10",
},
},
},
});
const TimerDescription = styled('span', {
flex: 1,
const TimerDescription = styled("span", {
flex: 1,
});
const TimerActions = styled('div', {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
const TimerActions = styled("div", {
display: "flex",
alignItems: "center",
gap: "0.25rem",
});
const TimerText = styled('div', {
fontFamily: 'Space Mono',
fontSize: '10pt',
margin: '0 -0.5rem',
marginTop: '0',
marginBottom: '0.3rem',
padding: '0.5rem',
backgroundColor: '$gray4',
lineHeight: '1.2rem',
'&:last-child': {
marginBottom: '-0.5rem',
},
const TimerText = styled("div", {
fontFamily: "Space Mono",
fontSize: "10pt",
margin: "0 -0.5rem",
marginTop: "0",
marginBottom: "0.3rem",
padding: "0.5rem",
backgroundColor: "$gray4",
lineHeight: "1.2rem",
"&:last-child": {
marginBottom: "-0.5rem",
},
});
function humanTime(t: TFunction<'translation'>, secs: number): string {
const mins = Math.floor(secs / 60);
const hrs = Math.floor(mins / 60);
function humanTime(t: TFunction<"translation">, secs: number): string {
const mins = Math.floor(secs / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return t('time.x-hours', { time: hrs });
}
if (mins > 0) {
return t('time.x-minutes', { time: mins });
}
return t('time.x-seconds', { time: secs });
if (hrs > 0) {
return t("time.x-hours", { time: hrs });
}
if (mins > 0) {
return t("time.x-minutes", { time: mins });
}
return t("time.x-seconds", { time: secs });
}
interface TimerItemProps {
name: string;
item: TwitchChatTimer;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
name: string;
item: TwitchChatTimer;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
function TimerItem({
name,
item,
onToggle,
onEdit,
onDelete,
}: TimerItemProps): React.ReactElement {
const { t } = useTranslation();
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>
<TimerDescription>
(
{t('pages.bottimers.timer-parameters', {
time: humanTime(t, item.minimum_delay),
messages: item.minimum_chat_activity,
interval: humanTime(t, 300),
})}
)
</TimerDescription>
<TimerActions>
<MultiButton>
<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)}
>
{t('form-actions.edit')}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t('form-actions.delete')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.bottimers.remove-timer-title', { name })}
description={t('form-actions.warning-delete')}
actionText={t('form-actions.delete')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</TimerActions>
</TimerHeader>
{item.messages?.map((message, index) => (
<TimerText key={index}>{message}</TimerText>
))}
</TimerItemContainer>
);
return (
<TimerItemContainer status={item.enabled ? "enabled" : "disabled"}>
<TimerHeader>
<TimerName status={item.enabled ? "enabled" : "disabled"}>{name}</TimerName>
<TimerDescription>
(
{t("pages.bottimers.timer-parameters", {
time: humanTime(t, item.minimum_delay),
messages: item.minimum_chat_activity,
interval: humanTime(t, 300),
})}
)
</TimerDescription>
<TimerActions>
<MultiButton>
<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)}>
{t("form-actions.edit")}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t("form-actions.delete")}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t("pages.bottimers.remove-timer-title", { name })}
description={t("form-actions.warning-delete")}
actionText={t("form-actions.delete")}
actionButtonProps={{ variation: "danger" }}
showCancel={true}
onAction={() => (onDelete ? onDelete() : null)}
/>
</Alert>
</MultiButton>
</TimerActions>
</TimerHeader>
{item.messages?.map((message, index) => (
<TimerText key={index}>{message}</TimerText>
))}
</TimerItemContainer>
);
}
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchChatTimer };
type DialogPrompt = { kind: "new" } | { kind: "edit"; name: string; item: TwitchChatTimer };
function TimerDialog({
kind,
name,
item,
onSubmit,
kind,
name,
item,
onSubmit,
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchChatTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void;
kind: "new" | "edit";
name?: string;
item?: TwitchChatTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void;
}) {
const [timerConfig] = useModule(modules.twitchChatTimers);
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 { t } = useTranslation();
const [timerConfig] = useModule(modules.twitchChatTimers);
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 { t } = useTranslation();
return (
<DialogContent
title={t(`pages.bottimers.timer-header-${kind}`)}
closeButton={true}
>
<form
onSubmit={(e) => {
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
e.preventDefault();
if (onSubmit) {
onSubmit(timerName, {
...item,
messages,
minimum_delay: minDelay,
minimum_chat_activity: minActivity,
});
}
}}
>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-name">{t('pages.bottimers.timer-name')}</Label>
<InputBox
id="timer-name"
value={timerName}
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
) {
(e.target as HTMLInputElement).setCustomValidity(
t('pages.bottimers.name-already-in-use'),
);
} else {
(e.target as HTMLInputElement).setCustomValidity('');
}
}}
placeholder={t('pages.bottimers.timer-name-placeholder')}
required={true}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-interval">
{t('pages.bottimers.timer-interval')}
</Label>
<FlexRow align="left">
<Interval
id="timer-interval"
value={minDelay}
onChange={setMinDelay}
active={true}
min={60}
units={[minutes, hours]}
required={true}
/>
</FlexRow>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-activity">
{t('pages.bottimers.timer-activity')}
</Label>
<FlexRow align="left" spacing={1}>
<InputBox
id="timer-activity"
defaultValue={minActivity}
type="number"
css={{
width: '5rem',
}}
required={true}
onChange={(ev) => {
const intNum = parseInt(ev.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
setMinActivity(intNum);
}}
placeholder="#"
/>
<span>{t('pages.bottimers.timer-activity-desc')}</span>
</FlexRow>
</Field>
return (
<DialogContent title={t(`pages.bottimers.timer-header-${kind}`)} closeButton={true}>
<form
onSubmit={(e) => {
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
e.preventDefault();
if (onSubmit) {
onSubmit(timerName, {
...item,
messages,
minimum_delay: minDelay,
minimum_chat_activity: minActivity,
});
}
}}
>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-name">{t("pages.bottimers.timer-name")}</Label>
<InputBox
id="timer-name"
value={timerName}
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) {
(e.target as HTMLInputElement).setCustomValidity(
t("pages.bottimers.name-already-in-use"),
);
} else {
(e.target as HTMLInputElement).setCustomValidity("");
}
}}
placeholder={t("pages.bottimers.timer-name-placeholder")}
required={true}
/>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-interval">{t("pages.bottimers.timer-interval")}</Label>
<FlexRow align="left">
<Interval
id="timer-interval"
value={minDelay}
onChange={setMinDelay}
active={true}
min={60}
units={[minutes, hours]}
required={true}
/>
</FlexRow>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label htmlFor="timer-activity">{t("pages.bottimers.timer-activity")}</Label>
<FlexRow align="left" spacing={1}>
<InputBox
id="timer-activity"
defaultValue={minActivity}
type="number"
css={{
width: "5rem",
}}
required={true}
onChange={(ev) => {
const intNum = Number.parseInt(ev.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
setMinActivity(intNum);
}}
placeholder="#"
/>
<span>{t("pages.bottimers.timer-activity-desc")}</span>
</FlexRow>
</Field>
<Field spacing="narrow" size="fullWidth">
<Label>{t('pages.bottimers.timer-messages')}</Label>
<MultiInput required={true} value={messages} onChange={setMessages} />
</Field>
<Field spacing="narrow" size="fullWidth">
<Label>{t("pages.bottimers.timer-messages")}</Label>
<MultiInput required={true} value={messages} onChange={setMessages} />
</Field>
<DialogActions>
<Button variation="primary">
{kind === 'new' ? t('form-actions.create') : t('form-actions.edit')}
</Button>
<DialogClose asChild>
<Button type="button">{t('form-actions.cancel')}</Button>
</DialogClose>
</DialogActions>
</form>
</DialogContent>
);
<DialogActions>
<Button variation="primary">
{kind === "new" ? t("form-actions.create") : t("form-actions.edit")}
</Button>
<DialogClose asChild>
<Button type="button">{t("form-actions.cancel")}</Button>
</DialogClose>
</DialogActions>
</form>
</DialogContent>
);
}
export default function TwitchChatTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers);
const [filter, setFilter] = useState("");
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const filterLC = filter.toLowerCase();
const filterLC = filter.toLowerCase();
const setTimer = (newName: string, data: TwitchChatTimer): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[newName]: {
...data,
enabled: true,
},
},
}),
);
break;
case 'edit': {
const oldName = activeDialog.name;
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[oldName]: undefined,
[newName]: data,
},
}),
);
break;
}
}
setActiveDialog(null);
};
const setTimer = (newName: string, data: TwitchChatTimer): void => {
switch (activeDialog.kind) {
case "new":
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[newName]: {
...data,
enabled: true,
},
},
}),
);
break;
case "edit": {
const oldName = activeDialog.name;
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[oldName]: undefined,
[newName]: data,
},
}),
);
break;
}
}
setActiveDialog(null);
};
const deleteTimer = (cmd: string): void => {
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[cmd]: undefined,
},
}),
);
};
const deleteTimer = (cmd: string): void => {
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[cmd]: undefined,
},
}),
);
};
const toggleTimer = (cmd: string): void => {
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[cmd]: {
...timerConfig.timers[cmd],
enabled: !timerConfig.timers[cmd].enabled,
},
},
}),
);
};
const toggleTimer = (cmd: string): void => {
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[cmd]: {
...timerConfig.timers[cmd],
enabled: !timerConfig.timers[cmd].enabled,
},
},
}),
);
};
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.bottimers.title')}</PageTitle>
<TextBlock>{t('pages.bottimers.desc')}</TextBlock>
</PageHeader>
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.bottimers.title")}</PageTitle>
<TextBlock>{t("pages.bottimers.desc")}</TextBlock>
</PageHeader>
<FlexRow spacing="1" align="left">
<Button
variation="primary"
onClick={() => setActiveDialog({ kind: 'new' })}
>
<PlusIcon /> {t('pages.bottimers.add-button')}
</Button>
<FlexRow spacing="1" align="left">
<Button variation="primary" onClick={() => setActiveDialog({ kind: "new" })}>
<PlusIcon /> {t("pages.bottimers.add-button")}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.bottimers.search-placeholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
<TimerList>
{timerConfig?.timers ? (
Object.keys(timerConfig?.timers ?? {})
?.filter((cmd) => cmd.toLowerCase().includes(filterLC))
.sort()
.map((cmd) => (
<TimerItem
key={cmd}
name={cmd}
item={timerConfig.timers[cmd]}
onToggle={() => toggleTimer(cmd)}
onEdit={() =>
setActiveDialog({
kind: 'edit',
name: cmd,
item: timerConfig.timers[cmd],
})
}
onDelete={() => deleteTimer(cmd)}
/>
))
) : (
<NoneText>{t('pages.bottimers.no-timers')}</NoneText>
)}
</TimerList>
<InputBox
css={{ flex: 1 }}
placeholder={t("pages.bottimers.search-placeholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
<TimerList>
{timerConfig?.timers ? (
Object.keys(timerConfig?.timers ?? {})
?.filter((cmd) => cmd.toLowerCase().includes(filterLC))
.sort()
.map((cmd) => (
<TimerItem
key={cmd}
name={cmd}
item={timerConfig.timers[cmd]}
onToggle={() => toggleTimer(cmd)}
onEdit={() =>
setActiveDialog({
kind: "edit",
name: cmd,
item: timerConfig.timers[cmd],
})
}
onDelete={() => deleteTimer(cmd)}
/>
))
) : (
<NoneText>{t("pages.bottimers.no-timers")}</NoneText>
)}
</TimerList>
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog && (
<TimerDialog
{...activeDialog}
onSubmit={(name, data) => setTimer(name, data)}
/>
)}
</Dialog>
</PageContainer>
);
<Dialog
open={!!activeDialog}
onOpenChange={(open) => {
if (!open) {
// Reset dialog status on dialog close
setActiveDialog(null);
}
}}
>
{activeDialog && (
<TimerDialog {...activeDialog} onSubmit={(name, data) => setTimer(name, data)} />
)}
</Dialog>
</PageContainer>
);
}

View File

@ -1,85 +1,79 @@
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { CheckIcon } from "@radix-ui/react-icons";
import type React from "react";
import { useTranslation } from "react-i18next";
import { useModule } from "~/lib/react";
import { useAppDispatch } from "~/store";
import { modules } from "~/store/api/reducer";
import {
Checkbox,
CheckboxIndicator,
Field,
FlexRow,
Label,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../../../theme';
import TwitchAPISettings from './TwitchAPISettings';
import TwitchEventSubSettings from './TwitchEventSubSettings';
import TwitchChatSettings from './TwitchChatSettings';
Checkbox,
CheckboxIndicator,
Field,
FlexRow,
Label,
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from "../../../theme";
import TwitchAPISettings from "./TwitchAPISettings";
import TwitchEventSubSettings from "./TwitchEventSubSettings";
import TwitchChatSettings from "./TwitchChatSettings";
export default function TwitchSettingsPage(): React.ReactElement {
const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const dispatch = useAppDispatch();
const active = twitchConfig?.enabled ?? false;
const active = twitchConfig?.enabled ?? false;
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.twitch-settings.title')}</PageTitle>
<TextBlock>{t('pages.twitch-settings.subtitle')}</TextBlock>
<Field css={{ paddingTop: '1rem' }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setTwitchConfig({
...twitchConfig,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
return (
<PageContainer>
<PageHeader>
<PageTitle>{t("pages.twitch-settings.title")}</PageTitle>
<TextBlock>{t("pages.twitch-settings.subtitle")}</TextBlock>
<Field css={{ paddingTop: "1rem" }}>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) => {
void dispatch(
setTwitchConfig({
...twitchConfig,
enabled: !!ev,
}),
);
}}
id="enable"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable">{t('pages.twitch-settings.enable')}</Label>
</FlexRow>
</Field>
</PageHeader>
<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>
</TabList>
<TabContent value="api-config">
<TwitchAPISettings />
</TabContent>
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="chat-settings">
<TwitchChatSettings />
</TabContent>
</TabContainer>
</div>
</PageContainer>
);
<Label htmlFor="enable">{t("pages.twitch-settings.enable")}</Label>
</FlexRow>
</Field>
</PageHeader>
<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>
</TabList>
<TabContent value="api-config">
<TwitchAPISettings />
</TabContent>
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="chat-settings">
<TwitchChatSettings />
</TabContent>
</TabContainer>
</div>
</PageContainer>
);
}

View File

@ -1,195 +1,183 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { checkTwitchKeys } from '~/lib/twitch';
import BrowserLink from '../../../components/BrowserLink';
import DefinitionTable from '../../../components/DefinitionTable';
import RevealLink from '../../../components/utils/RevealLink';
import SaveButton from '../../../components/forms/SaveButton';
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useModule, useTimedStatus } from "~/lib/react";
import { useAppDispatch } from "~/store";
import apiReducer, { modules } from "~/store/api/reducer";
import { checkTwitchKeys } from "~/lib/twitch";
import BrowserLink from "../../../components/BrowserLink";
import DefinitionTable from "../../../components/DefinitionTable";
import RevealLink from "../../../components/utils/RevealLink";
import SaveButton from "../../../components/forms/SaveButton";
import {
Button,
ButtonGroup,
Field,
InputBox,
Label,
PasswordInputBox,
SectionHeader,
styled,
TextBlock,
} from '../../../theme';
import AlertContent from '../../../components/AlertContent';
import { Alert } from '../../../theme/alert';
Button,
ButtonGroup,
Field,
InputBox,
Label,
PasswordInputBox,
SectionHeader,
styled,
TextBlock,
} from "../../../theme";
import AlertContent from "../../../components/AlertContent";
import { Alert } from "../../../theme/alert";
const StepList = styled('ul', {
lineHeight: '1.5',
listStyleType: 'none',
listStylePosition: 'outside',
const StepList = styled("ul", {
lineHeight: "1.5",
listStyleType: "none",
listStylePosition: "outside",
});
const Step = styled('li', {
marginBottom: '0.5rem',
paddingLeft: '1rem',
'&::marker': {
color: '$teal11',
content: '▧',
display: 'inline-block',
marginLeft: '-0.5rem',
},
const Step = styled("li", {
marginBottom: "0.5rem",
paddingLeft: "1rem",
"&::marker": {
color: "$teal11",
content: "▧",
display: "inline-block",
marginLeft: "-0.5rem",
},
});
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 dispatch = useAppDispatch();
const [revealClientSecret, setRevealClientSecret] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
const { t } = useTranslation();
const [httpConfig] = useModule(modules.httpConfig);
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);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
const checkCredentials = async () => {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(
twitchConfig.api_client_id,
twitchConfig.api_client_secret,
);
setTestResult({ open: true });
} catch (e: unknown) {
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
};
const checkCredentials = async () => {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(twitchConfig.api_client_id, twitchConfig.api_client_secret);
setTestResult({ open: true });
} catch (e: unknown) {
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
};
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
ev.preventDefault();
}}
>
<SectionHeader spacing={'none'}>
{t('pages.twitch-settings.api-subheader')}
</SectionHeader>
<TextBlock>{t('pages.twitch-settings.apiguide-1')}</TextBlock>
<StepList>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-2">
{' '}
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
https://dev.twitch.tv/console/apps/create
</BrowserLink>
</Trans>
</Step>
<Step>
{t('pages.twitch-settings.apiguide-3')}
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
ev.preventDefault();
}}
>
<SectionHeader spacing={"none"}>{t("pages.twitch-settings.api-subheader")}</SectionHeader>
<TextBlock>{t("pages.twitch-settings.apiguide-1")}</TextBlock>
<StepList>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-2">
{" "}
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
https://dev.twitch.tv/console/apps/create
</BrowserLink>
</Trans>
</Step>
<Step>
{t("pages.twitch-settings.apiguide-3")}
<DefinitionTable
entries={{
[t('pages.twitch-settings.app-oauth-redirect-url')]: `http://${
httpConfig?.bind.indexOf(':') > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ':4337'}`
}/twitch/callback`,
[t('pages.twitch-settings.app-category')]: 'Broadcasting Suite',
}}
/>
</Step>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-4">
{'str1 '}
<b>str2</b>
</Trans>
</Step>
</StepList>
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
<Label htmlFor="clientid">
{t('pages.twitch-settings.app-client-id')}
</Label>
<InputBox
type="text"
id="clientid"
placeholder={t('pages.twitch-settings.app-client-id')}
required={true}
value={twitchConfig?.api_client_id ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_id: ev.target.value,
}),
)
}
/>
</Field>
<DefinitionTable
entries={{
[t("pages.twitch-settings.app-oauth-redirect-url")]: `http://${
httpConfig?.bind.indexOf(":") > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ":4337"}`
}/twitch/callback`,
[t("pages.twitch-settings.app-category")]: "Broadcasting Suite",
}}
/>
</Step>
<Step>
<Trans i18nKey="pages.twitch-settings.apiguide-4">
{"str1 "}
<b>str2</b>
</Trans>
</Step>
</StepList>
<Field size="fullWidth" css={{ marginTop: "2rem" }}>
<Label htmlFor="clientid">{t("pages.twitch-settings.app-client-id")}</Label>
<InputBox
type="text"
id="clientid"
placeholder={t("pages.twitch-settings.app-client-id")}
required={true}
value={twitchConfig?.api_client_id ?? ""}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_id: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="clientsecret">
{t('pages.twitch-settings.app-client-secret')}
<RevealLink
value={revealClientSecret}
setter={setRevealClientSecret}
/>
</Label>
<PasswordInputBox
reveal={revealClientSecret}
id="clientsecret"
placeholder={t('pages.twitch-settings.app-client-secret')}
required={true}
value={twitchConfig?.api_client_secret ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_secret: ev.target.value,
}),
)
}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
onClick={() => {
void checkCredentials();
}}
disabled={testing}
>
{t('pages.twitch-settings.test-button')}
</Button>
</ButtonGroup>
<Alert
defaultOpen={false}
open={testResult.open}
onOpenChange={(val: boolean) => {
setTestResult({ ...testResult, open: val });
}}
>
<AlertContent
variation={testResult.error ? 'danger' : 'default'}
description={
testResult.error
? t('pages.twitch-settings.test-failed', {
error: testResult.error.message,
})
: t('pages.twitch-settings.test-succeeded')
}
actionText={t('form-actions.ok')}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
<Field size="fullWidth">
<Label htmlFor="clientsecret">
{t("pages.twitch-settings.app-client-secret")}
<RevealLink value={revealClientSecret} setter={setRevealClientSecret} />
</Label>
<PasswordInputBox
reveal={revealClientSecret}
id="clientsecret"
placeholder={t("pages.twitch-settings.app-client-secret")}
required={true}
value={twitchConfig?.api_client_secret ?? ""}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_secret: ev.target.value,
}),
)
}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
onClick={() => {
void checkCredentials();
}}
disabled={testing}
>
{t("pages.twitch-settings.test-button")}
</Button>
</ButtonGroup>
<Alert
defaultOpen={false}
open={testResult.open}
onOpenChange={(val: boolean) => {
setTestResult({ ...testResult, open: val });
}}
>
<AlertContent
variation={testResult.error ? "danger" : "default"}
description={
testResult.error
? t("pages.twitch-settings.test-failed", {
error: testResult.error.message,
})
: t("pages.twitch-settings.test-succeeded")
}
actionText={t("form-actions.ok")}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
}

View File

@ -1,95 +1,79 @@
import { useTranslation } from 'react-i18next';
import { useLiveKeyString, useModule, useStatus } 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 { useTranslation } from "react-i18next";
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";
export default function TwitchChatSettings() {
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 dispatch = useAppDispatch();
const { t } = useTranslation();
const disabled = status?.type === 'pending';
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 = useTimedStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const disabled = status?.type === "pending";
return (
<form
onSubmit={(ev) => {
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<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
variation="primary"
type="button"
onClick={() => {
void startAuthFlow('chat');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
{authKeyValue && (
<Button
variation="danger"
type="button"
onClick={() => {
kv.deleteKey(authKey);
}}
>
{t('pages.twitch-settings.chat.clear-button')}
</Button>
)}
</FlexRow>
<TwitchUserBlock
authKey={'twitch/chat/chatter-account'}
noUserMessage={t('pages.twitch-settings.chat.default-user')}
/>
return (
<form
onSubmit={(ev) => {
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<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
variation="primary"
type="button"
onClick={() => {
void startAuthFlow("chat");
}}
>
<ExternalLinkIcon /> {t("pages.twitch-settings.events.auth-button")}
</Button>
{authKeyValue && (
<Button
variation="danger"
type="button"
onClick={() => {
kv.deleteKey(authKey);
}}
>
{t("pages.twitch-settings.chat.clear-button")}
</Button>
)}
</FlexRow>
<TwitchUserBlock
authKey={"twitch/chat/chatter-account"}
noUserMessage={t("pages.twitch-settings.chat.default-user")}
/>
<SectionHeader>{t('pages.twitch-settings.chat.header')}</SectionHeader>
<Field size="fullWidth">
<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
}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
<SectionHeader>{t("pages.twitch-settings.chat.header")}</SectionHeader>
<Field size="fullWidth">
<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}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: Number.parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
}

View File

@ -1,60 +1,56 @@
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import eventsubTests from '~/data/eventsub-tests';
import { useAppSelector } from '~/store';
import { startAuthFlow } from '~/lib/twitch';
import { Button, ButtonGroup, SectionHeader, TextBlock } from '../../../theme';
import TwitchUserBlock from '../../../components/TwitchUserBlock';
import { ExternalLinkIcon } from "@radix-ui/react-icons";
import { useTranslation } from "react-i18next";
import eventsubTests from "~/data/eventsub-tests";
import { useAppSelector } from "~/store";
import { startAuthFlow } from "~/lib/twitch";
import { Button, ButtonGroup, SectionHeader, TextBlock } from "../../../theme";
import TwitchUserBlock from "../../../components/TwitchUserBlock";
export default function TwitchEventSubSettings() {
const { t } = useTranslation();
const kv = useAppSelector((state) => state.api.client);
const { t } = useTranslation();
const kv = useAppSelector((state) => state.api.client);
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON(`twitch/ev/eventsub-event/${event}`, {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
date: new Date().toISOString(),
});
};
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON(`twitch/ev/eventsub-event/${event}`, {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
date: new Date().toISOString(),
});
};
return (
<>
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
<Button
variation="primary"
onClick={() => {
void startAuthFlow('stream');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
<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>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
return (
<>
<TextBlock>{t("pages.twitch-settings.events.auth-message")}</TextBlock>
<Button
variation="primary"
onClick={() => {
void startAuthFlow("stream");
}}
>
<ExternalLinkIcon /> {t("pages.twitch-settings.events.auth-button")}
</Button>
<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>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
}

View File

@ -1,6 +1,6 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { keyframes } from '@stitches/react';
import { lightMode, styled } from './theme';
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { keyframes } from "@stitches/react";
import { lightMode, styled } from "./theme";
export const Alert = AlertDialogPrimitive.Root;
export const AlertTrigger = AlertDialogPrimitive.Trigger;
@ -8,114 +8,113 @@ export const AlertAction = AlertDialogPrimitive.AlertDialogAction;
export const AlertCancel = AlertDialogPrimitive.AlertDialogCancel;
const overlayShow = keyframes({
'0%': { opacity: 0 },
'100%': { opacity: 1 },
"0%": { opacity: 0 },
"100%": { opacity: 1 },
});
const contentShow = keyframes({
'0%': { opacity: 0, transform: 'translate(-50%, -48%) scale(.96)' },
'100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
"0%": { opacity: 0, transform: "translate(-50%, -48%) scale(.96)" },
"100%": { opacity: 1, transform: "translate(-50%, -50%) scale(1)" },
});
export const AlertOverlay = styled(AlertDialogPrimitive.Overlay, {
backgroundColor: '$blackA10',
position: 'fixed',
inset: 0,
'@media (prefers-reduced-motion: no-preference)': {
animation: `${overlayShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
[`.${lightMode} &`]: {
backgroundColor: '$blackA8',
},
backgroundColor: "$blackA10",
position: "fixed",
inset: 0,
"@media (prefers-reduced-motion: no-preference)": {
animation: `${overlayShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
[`.${lightMode} &`]: {
backgroundColor: "$blackA8",
},
});
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',
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '600px',
maxHeight: '85vh',
padding: '1rem',
'@media (prefers-reduced-motion: no-preference)': {
animation: `${contentShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
border: '2px solid $teal8',
'&:focus': { outline: 'none' },
variants: {
variation: {
default: {},
danger: {
borderColor: '$red8',
},
},
},
backgroundColor: "$gray2",
borderRadius: "0.25rem",
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%",
transform: "translate(-50%, -50%)",
width: "90vw",
maxWidth: "600px",
maxHeight: "85vh",
padding: "1rem",
"@media (prefers-reduced-motion: no-preference)": {
animation: `${contentShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
border: "2px solid $teal8",
"&:focus": { outline: "none" },
variants: {
variation: {
default: {},
danger: {
borderColor: "$red8",
},
},
},
});
export const AlertTitle = styled(AlertDialogPrimitive.Title, {
fontWeight: 'bold',
color: '$gray12',
fontSize: '15pt',
borderBottom: '1px solid $teal6',
margin: '-1rem',
marginBottom: '1.5rem',
padding: '1rem',
lineHeight: '1.25',
variants: {
variation: {
default: {},
danger: {
borderBottomColor: '$red6',
},
},
},
fontWeight: "bold",
color: "$gray12",
fontSize: "15pt",
borderBottom: "1px solid $teal6",
margin: "-1rem",
marginBottom: "1.5rem",
padding: "1rem",
lineHeight: "1.25",
variants: {
variation: {
default: {},
danger: {
borderBottomColor: "$red6",
},
},
},
});
export const AlertDescription = styled(AlertDialogPrimitive.Description, {
margin: '10px 0 20px',
color: '$gray12',
fontSize: 15,
lineHeight: 1.5,
variants: {
variation: {
default: {},
danger: {
borderBottomColor: '$red12',
},
},
},
margin: "10px 0 20px",
color: "$gray12",
fontSize: 15,
lineHeight: 1.5,
variants: {
variation: {
default: {},
danger: {
borderBottomColor: "$red12",
},
},
},
});
export const AlertActions = styled('div', {
display: 'flex',
gap: '0.5rem',
justifyContent: 'flex-end',
borderTop: '1px solid $gray6',
margin: '-1rem',
marginTop: '1.5rem',
padding: '1rem 1.5rem',
export const AlertActions = styled("div", {
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
borderTop: "1px solid $gray6",
margin: "-1rem",
marginTop: "1.5rem",
padding: "1rem 1.5rem",
});
export const IconButton = styled('button', {
all: 'unset',
fontFamily: 'inherit',
borderRadius: '100%',
height: 25,
width: 25,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: '$teal11',
position: 'absolute',
cursor: 'pointer',
top: 15,
right: 15,
export const IconButton = styled("button", {
all: "unset",
fontFamily: "inherit",
borderRadius: "100%",
height: 25,
width: 25,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "$teal11",
position: "absolute",
cursor: "pointer",
top: 15,
right: 15,
'&:hover': { backgroundColor: '$teal4' },
'&:focus': { boxShadow: `0 0 0 2px $teal7` },
"&:hover": { backgroundColor: "$teal4" },
"&:focus": { boxShadow: "0 0 0 2px $teal7" },
});

View File

@ -1,3 +1,3 @@
export const APPNAME = 'strimertül';
export const APPNAME = "strimertül";
export default { APPNAME };

View File

@ -1,132 +1,131 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { keyframes } from '@stitches/react';
import { lightMode, styled } from './theme';
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { keyframes } from "@stitches/react";
import { lightMode, styled } from "./theme";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
const overlayShow = keyframes({
'0%': { opacity: 0 },
'100%': { opacity: 1 },
"0%": { opacity: 0 },
"100%": { opacity: 1 },
});
const contentShowCentered = keyframes({
'0%': { opacity: 0, transform: 'translate(-50%, -48%) scale(.96)' },
'100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
"0%": { opacity: 0, transform: "translate(-50%, -48%) scale(.96)" },
"100%": { opacity: 1, transform: "translate(-50%, -50%) scale(1)" },
});
const contentShowTop = keyframes({
'0%': { opacity: 0, transform: 'translate(-50%, -10%) scale(.96)' },
'100%': { opacity: 1, transform: 'translate(-50%, 0%) scale(1)' },
"0%": { opacity: 0, transform: "translate(-50%, -10%) scale(.96)" },
"100%": { opacity: 1, transform: "translate(-50%, 0%) scale(1)" },
});
export const DialogOverlay = styled(DialogPrimitive.Overlay, {
backgroundColor: '$blackA10',
position: 'fixed',
inset: 0,
'@media (prefers-reduced-motion: no-preference)': {
animation: `${overlayShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
backgroundColor: "$blackA10",
position: "fixed",
inset: 0,
"@media (prefers-reduced-motion: no-preference)": {
animation: `${overlayShow()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
[`.${lightMode} &`]: {
backgroundColor: '$blackA8',
},
[`.${lightMode} &`]: {
backgroundColor: "$blackA8",
},
});
const dialogPadding = '50px';
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',
position: 'fixed',
left: '50%',
top: dialogPadding,
transform: 'translate(-50%, 0%)',
width: '90vw',
maxWidth: '600px',
maxHeight: '85vh',
padding: '1rem',
overflow: 'auto',
'&:focus': { outline: 'none' },
'@media (prefers-reduced-motion: no-preference)': {
animation: `${contentShowTop()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
variants: {
verticalPosition: {
centered: {
top: '50%',
transform: 'translate(-50%, -50%)',
},
'@media (prefers-reduced-motion: no-preference)': {
animation: `${contentShowCentered()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
},
size: {
stretch: {
width: 'auto',
maxWidth: 'none',
maxHeight: 'none',
top: dialogPadding,
bottom: dialogPadding,
left: dialogPadding,
right: dialogPadding,
transform: 'none',
},
},
},
backgroundColor: "$gray2",
borderRadius: "0.25rem",
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,
transform: "translate(-50%, 0%)",
width: "90vw",
maxWidth: "600px",
maxHeight: "85vh",
padding: "1rem",
overflow: "auto",
"&:focus": { outline: "none" },
"@media (prefers-reduced-motion: no-preference)": {
animation: `${contentShowTop()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
variants: {
verticalPosition: {
centered: {
top: "50%",
transform: "translate(-50%, -50%)",
},
"@media (prefers-reduced-motion: no-preference)": {
animation: `${contentShowCentered()} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
},
},
size: {
stretch: {
width: "auto",
maxWidth: "none",
maxHeight: "none",
top: dialogPadding,
bottom: dialogPadding,
left: dialogPadding,
right: dialogPadding,
transform: "none",
},
},
},
});
export const DialogTitle = styled(DialogPrimitive.Title, {
fontWeight: 'bold',
color: '$gray12',
fontSize: '15pt',
borderBottom: '1px solid $teal6',
margin: '-1rem',
marginBottom: '1.5rem',
padding: '1rem',
lineHeight: '1.25',
position: 'sticky',
top: '-1rem',
backgroundColor: '$gray2',
fontWeight: "bold",
color: "$gray12",
fontSize: "15pt",
borderBottom: "1px solid $teal6",
margin: "-1rem",
marginBottom: "1.5rem",
padding: "1rem",
lineHeight: "1.25",
position: "sticky",
top: "-1rem",
backgroundColor: "$gray2",
});
export const DialogDescription = styled(DialogPrimitive.Description, {
margin: '10px 0 20px',
color: '$teal11',
fontSize: 15,
lineHeight: 1.5,
margin: "10px 0 20px",
color: "$teal11",
fontSize: 15,
lineHeight: 1.5,
});
export const DialogActions = styled('div', {
display: 'flex',
gap: '0.5rem',
justifyContent: 'flex-end',
borderTop: '1px solid $gray6',
margin: '-1rem',
marginTop: '2rem',
padding: '1rem 1.5rem',
position: 'sticky',
backgroundColor: '$gray2',
bottom: '-1rem',
export const DialogActions = styled("div", {
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
borderTop: "1px solid $gray6",
margin: "-1rem",
marginTop: "2rem",
padding: "1rem 1.5rem",
position: "sticky",
backgroundColor: "$gray2",
bottom: "-1rem",
});
export const IconButton = styled('button', {
all: 'unset',
fontFamily: 'inherit',
borderRadius: '100%',
height: 25,
width: 25,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: '$teal11',
position: 'absolute',
cursor: 'pointer',
top: 15,
right: 15,
export const IconButton = styled("button", {
all: "unset",
fontFamily: "inherit",
borderRadius: "100%",
height: 25,
width: 25,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "$teal11",
position: "absolute",
cursor: "pointer",
top: 15,
right: 15,
'&:hover': { backgroundColor: '$teal4' },
'&:focus': { boxShadow: `0 0 0 2px $teal7` },
"&:hover": { backgroundColor: "$teal4" },
"&:focus": { boxShadow: "0 0 0 2px $teal7" },
});

View File

@ -1,361 +1,361 @@
import * as UnstyledLabel from '@radix-ui/react-label';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import * as ToggleGroup from '@radix-ui/react-toggle-group';
import { lightMode, styled, theme } from './theme';
import ControlledInput from '../components/forms/ControlledInput';
import PasswordField from '../components/forms/PasswordField';
import * as UnstyledLabel from "@radix-ui/react-label";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import * as ToggleGroup from "@radix-ui/react-toggle-group";
import { lightMode, styled, theme } from "./theme";
import ControlledInput from "../components/forms/ControlledInput";
import PasswordField from "../components/forms/PasswordField";
export const Field = styled('fieldset', {
all: 'unset',
marginBottom: '2rem',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
gap: '0.5rem',
variants: {
spacing: {
narrow: {
marginBottom: '1rem',
},
none: {
marginBottom: 0,
},
},
size: {
fullWidth: {
flexDirection: 'column',
alignItems: 'stretch',
},
vertical: {
flexDirection: 'column',
alignItems: 'flex-start',
},
},
},
export const Field = styled("fieldset", {
all: "unset",
marginBottom: "2rem",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: "0.5rem",
variants: {
spacing: {
narrow: {
marginBottom: "1rem",
},
none: {
marginBottom: 0,
},
},
size: {
fullWidth: {
flexDirection: "column",
alignItems: "stretch",
},
vertical: {
flexDirection: "column",
alignItems: "flex-start",
},
},
},
});
export const FieldNote = styled('small', {
display: 'block',
fontSize: '0.8rem',
padding: '0 0.2rem',
fontWeight: '300',
export const FieldNote = styled("small", {
display: "block",
fontSize: "0.8rem",
padding: "0 0.2rem",
fontWeight: "300",
});
export const Label = styled(UnstyledLabel.Root, {
userSelect: 'none',
fontWeight: 'bold',
userSelect: "none",
fontWeight: "bold",
});
const inputStyles = {
all: 'unset',
fontWeight: '300',
border: '1px solid $gray6',
padding: '0.5rem',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2',
transition: 'all 80ms',
'&:hover': {
borderColor: '$teal7',
},
'&:focus': {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
'&:invalid': {
borderColor: '$red5',
},
variants: {
border: {
none: {
borderWidth: '0',
},
},
},
[`.${lightMode} &`]: {
border: '1px solid $gray7',
'&:disabled': {
borderColor: '$gray4',
},
},
all: "unset",
fontWeight: "300",
border: "1px solid $gray6",
padding: "0.5rem",
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray2",
transition: "all 80ms",
"&:hover": {
borderColor: "$teal7",
},
"&:focus": {
borderColor: "$teal7",
backgroundColor: "$gray3",
},
"&:disabled": {
backgroundColor: "$gray4",
borderColor: "$gray5",
color: "$gray8",
},
"&:invalid": {
borderColor: "$red5",
},
variants: {
border: {
none: {
borderWidth: "0",
},
},
},
[`.${lightMode} &`]: {
border: "1px solid $gray7",
"&:disabled": {
borderColor: "$gray4",
},
},
} as const;
export const InputBox = styled('input', inputStyles);
export const InputBox = styled("input", inputStyles);
export const ControlledInputBox = styled(ControlledInput, inputStyles);
export const PasswordInputBox = styled(PasswordField, inputStyles);
export const Textarea = styled('textarea', {
all: 'unset',
fontWeight: '300',
border: '1px solid $gray6',
padding: '0.5rem',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2',
transition: 'all 80ms',
'&:hover': {
borderColor: '$teal7',
},
'&:focus': {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
'&:invalid': {
borderColor: '$red5',
},
[`.${lightMode} &`]: {
'&:disabled': {
borderColor: '$gray4',
},
},
variants: {
border: {
none: {
borderWidth: '0',
},
},
},
export const Textarea = styled("textarea", {
all: "unset",
fontWeight: "300",
border: "1px solid $gray6",
padding: "0.5rem",
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray2",
transition: "all 80ms",
"&:hover": {
borderColor: "$teal7",
},
"&:focus": {
borderColor: "$teal7",
backgroundColor: "$gray3",
},
"&:disabled": {
backgroundColor: "$gray4",
borderColor: "$gray5",
color: "$gray8",
},
"&:invalid": {
borderColor: "$red5",
},
[`.${lightMode} &`]: {
"&:disabled": {
borderColor: "$gray4",
},
},
variants: {
border: {
none: {
borderWidth: "0",
},
},
},
});
export const ButtonGroup = styled('div', {
display: 'flex',
gap: '0.5rem',
export const ButtonGroup = styled("div", {
display: "flex",
gap: "0.5rem",
});
export const MultiButton = styled('div', {
display: 'flex',
export const MultiButton = styled("div", {
display: "flex",
});
function buttonStyle(hueName: string) {
return {
border: `1px solid $${hueName}6`,
backgroundColor: `$${hueName}4`,
'&:not(:disabled)': {
'&:hover': {
backgroundColor: `$${hueName}5`,
borderColor: `$${hueName}8`,
},
'&:active': {
background: `$${hueName}6`,
},
},
[`.${lightMode} &`]: {
border: `1px solid $${hueName}10`,
backgroundColor: `$${hueName}10`,
color: `$${hueName}2`,
'&:not(:disabled)': {
'&:hover': {
backgroundColor: `$${hueName}11`,
},
'&:active': {
background: `$${hueName}11`,
},
},
},
};
return {
border: `1px solid $${hueName}6`,
backgroundColor: `$${hueName}4`,
"&:not(:disabled)": {
"&:hover": {
backgroundColor: `$${hueName}5`,
borderColor: `$${hueName}8`,
},
"&:active": {
background: `$${hueName}6`,
},
},
[`.${lightMode} &`]: {
border: `1px solid $${hueName}10`,
backgroundColor: `$${hueName}10`,
color: `$${hueName}2`,
"&:not(:disabled)": {
"&:hover": {
backgroundColor: `$${hueName}11`,
},
"&:active": {
background: `$${hueName}11`,
},
},
},
};
}
const button = {
all: 'unset',
cursor: 'pointer',
userSelect: 'none',
color: '$gray12',
fontWeight: '300',
borderRadius: theme.borderRadius.form,
fontSize: '1.1rem',
padding: '0.5rem 1rem',
border: '1px solid $gray6',
backgroundColor: '$gray4',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
'&:not(:disabled)': {
'&:hover': {
backgroundColor: '$gray5',
borderColor: '$gray8',
},
'&:active': {
background: '$gray6',
},
},
'&:disabled': {
border: '1px solid $gray4',
backgroundColor: '$gray3',
cursor: 'not-allowed',
},
[`.${lightMode} &`]: {
backgroundColor: '$gray2',
border: '1px solid $gray7',
'&:disabled': {
border: '1px solid $gray4',
backgroundColor: '$gray3',
cursor: 'not-allowed',
},
},
transition: 'all 0.2s',
variants: {
border: {
none: {
borderWidth: '0',
},
},
styling: {
form: {
padding: '0.65rem',
},
link: {
backgroundColor: 'transparent',
border: 'none',
color: '$teal11',
textDecoration: 'underline',
},
multi: {
borderRadius: '0',
margin: '0 -1px',
'&:first-child': {
borderRadius: `$borderRadius$form 0 0 $borderRadius$form`,
},
'&:last-child': {
borderRadius: `0 $borderRadius$form $borderRadius$form 0`,
},
'&:hover': {
zIndex: '1',
},
},
},
size: {
small: {
padding: '0.3rem 0.5rem',
fontSize: '0.9rem',
},
smaller: {
padding: '5px',
paddingBottom: '3px',
fontSize: '0.8rem',
},
},
variation: {
primary: buttonStyle('teal'),
success: buttonStyle('grass'),
error: buttonStyle('red'),
warning: buttonStyle('yellow'),
danger: buttonStyle('red'),
},
},
all: "unset",
cursor: "pointer",
userSelect: "none",
color: "$gray12",
fontWeight: "300",
borderRadius: theme.borderRadius.form,
fontSize: "1.1rem",
padding: "0.5rem 1rem",
border: "1px solid $gray6",
backgroundColor: "$gray4",
display: "flex",
alignItems: "center",
gap: "0.5rem",
"&:not(:disabled)": {
"&:hover": {
backgroundColor: "$gray5",
borderColor: "$gray8",
},
"&:active": {
background: "$gray6",
},
},
"&:disabled": {
border: "1px solid $gray4",
backgroundColor: "$gray3",
cursor: "not-allowed",
},
[`.${lightMode} &`]: {
backgroundColor: "$gray2",
border: "1px solid $gray7",
"&:disabled": {
border: "1px solid $gray4",
backgroundColor: "$gray3",
cursor: "not-allowed",
},
},
transition: "all 0.2s",
variants: {
border: {
none: {
borderWidth: "0",
},
},
styling: {
form: {
padding: "0.65rem",
},
link: {
backgroundColor: "transparent",
border: "none",
color: "$teal11",
textDecoration: "underline",
},
multi: {
borderRadius: "0",
margin: "0 -1px",
"&:first-child": {
borderRadius: "$borderRadius$form 0 0 $borderRadius$form",
},
"&:last-child": {
borderRadius: "0 $borderRadius$form $borderRadius$form 0",
},
"&:hover": {
zIndex: "1",
},
},
},
size: {
small: {
padding: "0.3rem 0.5rem",
fontSize: "0.9rem",
},
smaller: {
padding: "5px",
paddingBottom: "3px",
fontSize: "0.8rem",
},
},
variation: {
primary: buttonStyle("teal"),
success: buttonStyle("grass"),
error: buttonStyle("red"),
warning: buttonStyle("yellow"),
danger: buttonStyle("red"),
},
},
} as const;
export const MultiToggle = styled(ToggleGroup.Root, {
display: 'inline-flex',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray4',
display: "inline-flex",
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray4",
});
export const MultiToggleItem = styled(ToggleGroup.Item, {
...button,
borderRadius: 0,
border: 0,
'&:first-child': {
borderTopLeftRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
},
'&:last-child': {
borderTopRightRadius: theme.borderRadius.form,
borderBottomRightRadius: theme.borderRadius.form,
},
'&:not(:disabled)': {
'&:hover': {
...button['&:not(:disabled)']['&:hover'],
},
"&[data-state='on']": {
...button['&:not(:disabled)']['&:active'],
backgroundColor: '$gray8',
},
},
...button,
borderRadius: 0,
border: 0,
"&:first-child": {
borderTopLeftRadius: theme.borderRadius.form,
borderBottomLeftRadius: theme.borderRadius.form,
},
"&:last-child": {
borderTopRightRadius: theme.borderRadius.form,
borderBottomRightRadius: theme.borderRadius.form,
},
"&:not(:disabled)": {
"&:hover": {
...button["&:not(:disabled)"]["&:hover"],
},
"&[data-state='on']": {
...button["&:not(:disabled)"]["&:active"],
backgroundColor: "$gray8",
},
},
});
export const Button = styled('button', {
...button,
export const Button = styled("button", {
...button,
});
export const ComboBox = styled('select', {
margin: 0,
color: '$teal13',
fontWeight: '300',
border: '1px solid $gray6',
padding: '0.5rem',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2',
'&:hover': {
borderColor: '$teal7',
},
'&:focus': {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
variants: {
border: {
none: {
borderWidth: '0',
},
},
},
export const ComboBox = styled("select", {
margin: 0,
color: "$teal13",
fontWeight: "300",
border: "1px solid $gray6",
padding: "0.5rem",
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray2",
"&:hover": {
borderColor: "$teal7",
},
"&:focus": {
borderColor: "$teal7",
backgroundColor: "$gray3",
},
"&:disabled": {
backgroundColor: "$gray4",
borderColor: "$gray5",
color: "$gray8",
},
variants: {
border: {
none: {
borderWidth: "0",
},
},
},
});
export const Checkbox = styled(CheckboxPrimitive.Root, {
all: 'unset',
width: 25,
height: 25,
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid $gray6',
backgroundColor: '$gray3',
transition: 'all 60ms',
'&:hover': {
borderColor: '$teal6',
backgroundColor: '$gray5',
},
'&:active': {
background: '$gray6',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
variants: {
variation: {
primary: {
border: '1px solid $teal6',
backgroundColor: '$teal4',
'&:hover': {
backgroundColor: '$teal5',
},
'&:active': {
background: '$teal6',
},
},
},
},
all: "unset",
width: 25,
height: 25,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid $gray6",
backgroundColor: "$gray3",
transition: "all 60ms",
"&:hover": {
borderColor: "$teal6",
backgroundColor: "$gray5",
},
"&:active": {
background: "$gray6",
},
"&:disabled": {
backgroundColor: "$gray4",
borderColor: "$gray5",
color: "$gray8",
},
variants: {
variation: {
primary: {
border: "1px solid $teal6",
backgroundColor: "$teal4",
"&:hover": {
backgroundColor: "$teal5",
},
"&:active": {
background: "$teal6",
},
},
},
},
});
export const CheckboxIndicator = styled(CheckboxPrimitive.Indicator, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '$teal11',
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "$teal11",
});

View File

@ -1,8 +1,8 @@
export * from './brand';
export * from './dialog';
export * from './forms';
export * from './pages';
export * from './tabs';
export * from './theme';
export * from './toolbar';
export * from './utils';
export * from "./brand";
export * from "./dialog";
export * from "./forms";
export * from "./pages";
export * from "./tabs";
export * from "./theme";
export * from "./toolbar";
export * from "./utils";

View File

@ -1,56 +1,56 @@
import { styled } from './theme';
import { styled } from "./theme";
export const PageContainer = styled('div', {
padding: '2rem',
paddingTop: '1rem',
maxWidth: '1000px',
width: '100%',
margin: '0 auto',
variants: {
spacing: {
narrow: {
padding: '0 2rem',
paddingTop: '0',
},
},
},
export const PageContainer = styled("div", {
padding: "2rem",
paddingTop: "1rem",
maxWidth: "1000px",
width: "100%",
margin: "0 auto",
variants: {
spacing: {
narrow: {
padding: "0 2rem",
paddingTop: "0",
},
},
},
});
export const PageHeader = styled('header', {});
export const PageHeader = styled("header", {});
export const PageTitle = styled('h1', {
fontSize: '25pt',
fontWeight: '600',
marginBottom: '1rem',
export const PageTitle = styled("h1", {
fontSize: "25pt",
fontWeight: "600",
marginBottom: "1rem",
});
export const SectionHeader = styled('h2', {
fontSize: '18pt',
paddingTop: '1rem',
variants: {
spacing: {
none: {
paddingTop: '0',
},
},
},
export const SectionHeader = styled("h2", {
fontSize: "18pt",
paddingTop: "1rem",
variants: {
spacing: {
none: {
paddingTop: "0",
},
},
},
});
export const TextBlock = styled('p', {
lineHeight: '1.5',
variants: {
spacing: {
none: {
margin: '0',
},
},
},
export const TextBlock = styled("p", {
lineHeight: "1.5",
variants: {
spacing: {
none: {
margin: "0",
},
},
},
});
export const NoneText = styled('div', {
color: '$gray9',
fontSize: '1.2em',
textAlign: 'center',
fontStyle: 'italic',
paddingTop: '1rem',
export const NoneText = styled("div", {
color: "$gray9",
fontSize: "1.2em",
textAlign: "center",
fontStyle: "italic",
paddingTop: "1rem",
});

View File

@ -1,37 +1,37 @@
import { styled } from './theme';
import { styled } from "./theme";
export const Table = styled('table', {
borderCollapse: 'collapse',
export const Table = styled("table", {
borderCollapse: "collapse",
});
export const TableRow = styled('tr', {
all: 'unset',
display: 'table-row',
padding: '0.5rem',
verticalAlign: 'middle',
textAlign: 'left',
backgroundColor: '$gray1',
'&:nth-child(even)': {
backgroundColor: '$gray2',
},
export const TableRow = styled("tr", {
all: "unset",
display: "table-row",
padding: "0.5rem",
verticalAlign: "middle",
textAlign: "left",
backgroundColor: "$gray1",
"&:nth-child(even)": {
backgroundColor: "$gray2",
},
});
export const TableHeader = styled('th', {
all: 'unset',
display: 'table-cell',
padding: '0.25rem 0.5rem',
height: '2rem',
verticalAlign: 'middle',
textAlign: 'left',
borderBottom: '3px solid $gray3',
fontWeight: 'bold',
color: '$teal11',
export const TableHeader = styled("th", {
all: "unset",
display: "table-cell",
padding: "0.25rem 0.5rem",
height: "2rem",
verticalAlign: "middle",
textAlign: "left",
borderBottom: "3px solid $gray3",
fontWeight: "bold",
color: "$teal11",
});
export const TableCell = styled('td', {
all: 'unset',
display: 'table-cell',
padding: '0.25rem 0.5rem',
verticalAlign: 'middle',
textAlign: 'left',
export const TableCell = styled("td", {
all: "unset",
display: "table-cell",
padding: "0.25rem 0.5rem",
verticalAlign: "middle",
textAlign: "left",
});

View File

@ -1,29 +1,29 @@
import * as Tabs from '@radix-ui/react-tabs';
import { styled } from './theme';
import * as Tabs from "@radix-ui/react-tabs";
import { styled } from "./theme";
export const TabContainer = styled(Tabs.Root, {
width: '100%',
width: "100%",
});
export const TabList = styled(Tabs.List, {
borderBottom: '1px solid $gray6',
borderBottom: "1px solid $gray6",
});
export const TabButton = styled(Tabs.Trigger, {
all: 'unset',
padding: '0.6rem 1.2rem',
borderBottom: 'none',
borderRadius: '0.2rem 0.2rem 0 0',
cursor: 'pointer',
'&[data-state="active"]': {
borderBottom: '2px solid $teal9',
},
marginBottom: '-1px',
'&:disabled': {
opacity: 0.3,
},
all: "unset",
padding: "0.6rem 1.2rem",
borderBottom: "none",
borderRadius: "0.2rem 0.2rem 0 0",
cursor: "pointer",
'&[data-state="active"]': {
borderBottom: "2px solid $teal9",
},
marginBottom: "-1px",
"&:disabled": {
opacity: 0.3,
},
});
export const TabContent = styled(Tabs.Content, {
paddingTop: '1.5rem',
paddingTop: "1.5rem",
});

View File

@ -1,88 +1,88 @@
import {
amberDark,
blackA,
crimson,
crimsonDark,
grass,
grassDark,
gray,
grayDark,
red,
redDark,
teal,
tealDark,
yellow,
yellowDark,
} from '@radix-ui/colors';
import { createStitches, createTheme, globalCss } from '@stitches/react';
amberDark,
blackA,
crimson,
crimsonDark,
grass,
grassDark,
gray,
grayDark,
red,
redDark,
teal,
tealDark,
yellow,
yellowDark,
} from "@radix-ui/colors";
import { createStitches, createTheme, globalCss } from "@stitches/react";
export const globalStyles = globalCss({
'*': { boxSizing: 'border-box' },
body: { margin: 0, padding: 0, backgroundColor: '$gray1', color: '$gray12' },
html: {
margin: 0,
padding: 0,
fontFamily: "'Inter', 'system-ui', sans-serif",
'@supports (font-variation-settings: normal)': {
fontFamily: "'InterVariable', 'system-ui', sans-serif",
},
},
a: {
color: '$teal11',
'&:visited': {
color: '$teal11',
},
},
p: {
lineHeight: 1.5,
},
'.monaco-editor': { position: 'absolute !important' },
"*": { boxSizing: "border-box" },
body: { margin: 0, padding: 0, backgroundColor: "$gray1", color: "$gray12" },
html: {
margin: 0,
padding: 0,
fontFamily: "'Inter', 'system-ui', sans-serif",
"@supports (font-variation-settings: normal)": {
fontFamily: "'InterVariable', 'system-ui', sans-serif",
},
},
a: {
color: "$teal11",
"&:visited": {
color: "$teal11",
},
},
p: {
lineHeight: 1.5,
},
".monaco-editor": { position: "absolute !important" },
});
export const { styled, theme } = createStitches({
theme: {
colors: {
...grayDark,
...tealDark,
...yellowDark,
...grassDark,
...redDark,
...crimsonDark,
...amberDark,
...blackA,
},
borderRadius: {
form: '0.3rem',
toolbar: '0.5rem',
},
},
media: {
thin: '(min-width: 480px)',
medium: '(min-width: 768px)',
wide: '(min-width: 1024px)',
},
theme: {
colors: {
...grayDark,
...tealDark,
...yellowDark,
...grassDark,
...redDark,
...crimsonDark,
...amberDark,
...blackA,
},
borderRadius: {
form: "0.3rem",
toolbar: "0.5rem",
},
},
media: {
thin: "(min-width: 480px)",
medium: "(min-width: 768px)",
wide: "(min-width: 1024px)",
},
});
export const lightMode = createTheme({
colors: {
...gray,
...teal,
...yellow,
...grass,
...red,
...crimson,
...amberDark,
...blackA,
},
colors: {
...gray,
...teal,
...yellow,
...grass,
...red,
...crimson,
...amberDark,
...blackA,
},
});
export const themes = ['dark', 'light'];
export const themes = ["dark", "light"];
export function getTheme(themeName: string) {
switch (themeName) {
case 'light':
return lightMode;
default:
return undefined;
}
switch (themeName) {
case "light":
return lightMode;
default:
return undefined;
}
}

View File

@ -1,60 +1,60 @@
import * as ToolbarPrimitive from '@radix-ui/react-toolbar';
import { styled, theme } from './theme';
import * as ToolbarPrimitive from "@radix-ui/react-toolbar";
import { styled, theme } from "./theme";
export const Toolbar = styled(ToolbarPrimitive.Root, {
display: 'flex',
padding: '0.4rem',
margin: '0.5rem 0',
width: '100%',
minWidth: 'max-content',
borderRadius: theme.borderRadius.toolbar,
backgroundColor: '$gray2',
alignItems: 'center',
display: "flex",
padding: "0.4rem",
margin: "0.5rem 0",
width: "100%",
minWidth: "max-content",
borderRadius: theme.borderRadius.toolbar,
backgroundColor: "$gray2",
alignItems: "center",
});
const itemStyles = {
all: 'unset',
flex: '0 0 auto',
color: '$gray12',
padding: '0.6rem 0.8rem',
borderRadius: theme.borderRadius.form,
display: 'flex',
fontSize: '0.9rem',
lineHeight: 1,
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
all: "unset",
flex: "0 0 auto",
color: "$gray12",
padding: "0.6rem 0.8rem",
borderRadius: theme.borderRadius.form,
display: "flex",
fontSize: "0.9rem",
lineHeight: 1,
alignItems: "center",
justifyContent: "center",
userSelect: "none",
} as const;
export const ToolbarButton = styled(ToolbarPrimitive.Button, {
...itemStyles,
cursor: 'pointer',
backgroundColor: '$gray4',
border: '1px solid $gray6',
...itemStyles,
cursor: "pointer",
backgroundColor: "$gray4",
border: "1px solid $gray6",
});
export const ToolbarComboBox = styled('select', {
flex: '0 0 auto',
color: '$gray12',
display: 'inline-flex',
lineHeight: 1,
fontSize: '0.9rem',
margin: 0,
fontWeight: '300',
border: '1px solid $gray6',
padding: '0.5rem 0.25rem',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2',
'&:hover': {
borderColor: '$teal7',
},
'&:focus': {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
export const ToolbarComboBox = styled("select", {
flex: "0 0 auto",
color: "$gray12",
display: "inline-flex",
lineHeight: 1,
fontSize: "0.9rem",
margin: 0,
fontWeight: "300",
border: "1px solid $gray6",
padding: "0.5rem 0.25rem",
borderRadius: theme.borderRadius.form,
backgroundColor: "$gray2",
"&:hover": {
borderColor: "$teal7",
},
"&:focus": {
borderColor: "$teal7",
backgroundColor: "$gray3",
},
"&:disabled": {
backgroundColor: "$gray4",
borderColor: "$gray5",
color: "$gray8",
},
});

View File

@ -1,41 +1,41 @@
/* eslint-disable import/prefer-default-export */
import { Content as HoverCardContent } from '@radix-ui/react-hover-card';
import { styled, theme } from './theme';
import { Content as HoverCardContent } from "@radix-ui/react-hover-card";
import { styled, theme } from "./theme";
export const FlexRow = styled('div', {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
variants: {
border: {
form: {
border: '1px solid $gray6',
borderRadius: theme.borderRadius.form,
},
},
spacing: {
'1': {
gap: '0.5rem',
},
},
align: {
left: {
justifyContent: 'flex-start',
},
},
},
export const FlexRow = styled("div", {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
variants: {
border: {
form: {
border: "1px solid $gray6",
borderRadius: theme.borderRadius.form,
},
},
spacing: {
"1": {
gap: "0.5rem",
},
},
align: {
left: {
justifyContent: "flex-start",
},
},
},
});
export const TooltipContent = styled(HoverCardContent, {
borderRadius: 6,
display: 'flex',
padding: '0.5rem',
gap: '0.5rem',
flexDirection: 'column',
border: '2px solid $gray6',
backgroundColor: '$gray2',
alignItems: 'flex-start',
boxShadow: '0px 5px 20px rgba(0,0,0,0.4)',
borderRadius: 6,
display: "flex",
padding: "0.5rem",
gap: "0.5rem",
flexDirection: "column",
border: "2px solid $gray6",
backgroundColor: "$gray2",
alignItems: "flex-start",
boxShadow: "0px 5px 20px rgba(0,0,0,0.4)",
});

View File

@ -12,49 +12,45 @@ 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] {
const result: number[] = [];
export default function decode(string: string): [number, number, number, number] {
const result: number[] = [];
let shift = 0;
let value = 0;
let shift = 0;
let value = 0;
for (let i = 0; i < string.length; i += 1) {
let integer = chatToInt[string[i]];
for (let i = 0; i < string.length; i += 1) {
let integer = chatToInt[string[i]];
if (integer === undefined) {
throw new Error(`Invalid character (${string[i]})`);
}
if (integer === undefined) {
throw new Error(`Invalid character (${string[i]})`);
}
const hasContinuationBit = integer & 32;
const hasContinuationBit = integer & 32;
integer &= 31;
value += integer << shift;
integer &= 31;
value += integer << shift;
if (hasContinuationBit) {
shift += 5;
} else {
const shouldNegate = value & 1;
value >>>= 1;
if (hasContinuationBit) {
shift += 5;
} else {
const shouldNegate = value & 1;
value >>>= 1;
if (shouldNegate) {
result.push(value === 0 ? -0x80000000 : -value);
} else {
result.push(value);
}
if (shouldNegate) {
result.push(value === 0 ? -0x80000000 : -value);
} else {
result.push(value);
}
// reset
value = 0;
shift = 0;
}
}
// reset
value = 0;
shift = 0;
}
}
return result as [number, number, number, number];
return result as [number, number, number, number];
}