mirror of https://git.sr.ht/~ashkeel/strimertul
Compare commits
2 Commits
d276b734bf
...
33f775f0a2
Author | SHA1 | Date |
---|---|---|
Ash Keel | 33f775f0a2 | |
Ash Keel | af1f198eba |
|
@ -1,46 +0,0 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh', '@typescript-eslint', 'import'],
|
||||
extends: [
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'prettier',
|
||||
],
|
||||
root: true,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
extraFileExtensions: ['.cjs'],
|
||||
},
|
||||
rules: {
|
||||
'no-console': 0,
|
||||
'import/extensions': 0,
|
||||
'no-use-before-define': 'off',
|
||||
'no-shadow': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-void': ['error', { allowAsStatement: true }],
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
'default-case': 'off',
|
||||
'consistent-return': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unsafe-return': ['error'],
|
||||
'@typescript-eslint/switch-exhaustiveness-check': ['error'],
|
||||
'@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
|
||||
'react-refresh/only-export-components': 'warn'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
moduleDirectory: ['node_modules', 'src/'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
ignorePatterns: ['OLD/*', 'wailsjs/*', 'dist/*'],
|
||||
};
|
|
@ -0,0 +1,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
|
||||
}
|
||||
}
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -43,22 +43,15 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"format": "biome format ./src --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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
894313fcc17ff294db3c3171003cb162
|
||||
67f333a2779a971376eeb65b025f5696
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends":"../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2019", "WebWorker"],
|
||||
}
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2019", "WebWorker"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 } }}
|
||||
>
|
||||
‹
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
aria-label={t('pagination.next')}
|
||||
title={t('pagination.next')}
|
||||
disabled={current >= max}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
css={{ flex: 1, '@medium': { flex: 0 } }}
|
||||
>
|
||||
›
|
||||
</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">…</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 } }}
|
||||
>
|
||||
‹
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
aria-label={t("pagination.next")}
|
||||
title={t("pagination.next")}
|
||||
disabled={current >= max}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
css={{ flex: 1, "@medium": { flex: 0 } }}
|
||||
>
|
||||
›
|
||||
</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">…</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">…</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">…</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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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" };
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
export const APPNAME = 'strimertül';
|
||||
export const APPNAME = "strimertül";
|
||||
|
||||
export default { APPNAME };
|
||||
|
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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)",
|
||||
});
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue