From 33f775f0a28a30986f87d72015a9bb7995badd2a Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Tue, 30 Apr 2024 18:38:16 +0200 Subject: [PATCH] biome format --- frontend/biome.json | 6 - frontend/package.json | 2 +- frontend/src/AppWrapper.tsx | 38 +- frontend/src/data/eventsub-tests.ts | 288 ++-- frontend/src/index.tsx | 40 +- frontend/src/lib/eventSub.ts | 556 ++++---- frontend/src/lib/extensions/extension.ts | 210 +-- frontend/src/lib/extensions/metadata.ts | 60 +- frontend/src/lib/extensions/sourceMap.ts | 26 +- frontend/src/lib/extensions/types.ts | 42 +- .../lib/extensions/workers/extensionHost.ts | 150 +- .../src/lib/extensions/workers/tsconfig.json | 8 +- frontend/src/lib/react.ts | 202 +-- frontend/src/lib/slug.ts | 22 +- frontend/src/lib/time.ts | 26 +- frontend/src/lib/twitch.ts | 64 +- frontend/src/locale/en/translation.json | 926 ++++++------- frontend/src/locale/it/translation.json | 926 ++++++------- frontend/src/locale/languages.ts | 34 +- frontend/src/locale/setup.ts | 26 +- frontend/src/store/api/reducer.ts | 676 ++++----- frontend/src/store/api/types.ts | 250 ++-- frontend/src/store/extensions/reducer.ts | 540 ++++---- frontend/src/store/index.ts | 40 +- frontend/src/store/logging/reducer.ts | 72 +- frontend/src/store/server/reducer.ts | 32 +- frontend/src/ui/App.tsx | 512 +++---- frontend/src/ui/ErrorWindow.tsx | 1020 +++++++------- frontend/src/ui/components/AlertContent.tsx | 118 +- frontend/src/ui/components/BrowserLink.tsx | 32 +- frontend/src/ui/components/DataTable.tsx | 230 ++-- .../src/ui/components/DefinitionTable.tsx | 52 +- frontend/src/ui/components/DialogContent.tsx | 74 +- .../ui/components/InteractiveAuthDialog.tsx | 232 ++-- frontend/src/ui/components/Loading.tsx | 68 +- frontend/src/ui/components/LogViewer.tsx | 856 ++++++------ frontend/src/ui/components/PageList.tsx | 282 ++-- frontend/src/ui/components/Sidebar.tsx | 546 ++++---- .../src/ui/components/TwitchUserBlock.tsx | 118 +- .../ui/components/forms/ControlledInput.tsx | 50 +- frontend/src/ui/components/forms/Interval.tsx | 142 +- .../src/ui/components/forms/MultiInput.tsx | 152 +-- .../src/ui/components/forms/PasswordField.tsx | 14 +- .../src/ui/components/forms/RadioGroup.tsx | 132 +- .../src/ui/components/forms/SaveButton.tsx | 46 +- frontend/src/ui/components/forms/units.tsx | 6 +- frontend/src/ui/components/utils/Channels.tsx | 44 +- .../src/ui/components/utils/RevealLink.tsx | 42 +- .../src/ui/components/utils/Scrollbar.tsx | 94 +- .../src/ui/components/utils/WIPNotice.tsx | 56 +- frontend/src/ui/pages/Dashboard.tsx | 1164 ++++++++-------- frontend/src/ui/pages/Onboarding.tsx | 1204 ++++++++--------- .../src/ui/pages/loyalty/LoyaltyConfig.tsx | 328 ++--- .../src/ui/pages/loyalty/LoyaltyQueue.tsx | 736 +++++----- .../src/ui/pages/loyalty/Rewards/GoalsTab.tsx | 664 ++++----- .../src/ui/pages/loyalty/Rewards/Page.tsx | 66 +- .../ui/pages/loyalty/Rewards/RewardsTab.tsx | 794 +++++------ .../src/ui/pages/loyalty/Rewards/theme.tsx | 120 +- frontend/src/ui/pages/system/Debug.tsx | 310 ++--- frontend/src/ui/pages/system/Extensions.tsx | 1116 +++++++-------- .../src/ui/pages/system/ServerSettings.tsx | 252 ++-- frontend/src/ui/pages/system/Strimertul.tsx | 252 ++-- .../src/ui/pages/system/UISettingsPage.tsx | 168 +-- frontend/src/ui/pages/twitch/ChatAlerts.tsx | 524 +++---- frontend/src/ui/pages/twitch/ChatCommands.tsx | 876 ++++++------ frontend/src/ui/pages/twitch/ChatTimers.tsx | 748 +++++----- .../ui/pages/twitch/TwitchSettings/Page.tsx | 144 +- .../TwitchSettings/TwitchAPISettings.tsx | 340 ++--- .../TwitchSettings/TwitchChatSettings.tsx | 148 +- .../TwitchSettings/TwitchEventSubSettings.tsx | 102 +- frontend/src/ui/theme/alert.ts | 184 +-- frontend/src/ui/theme/brand.ts | 2 +- frontend/src/ui/theme/dialog.ts | 200 +-- frontend/src/ui/theme/forms.ts | 640 ++++----- frontend/src/ui/theme/index.ts | 16 +- frontend/src/ui/theme/pages.ts | 90 +- frontend/src/ui/theme/table.ts | 58 +- frontend/src/ui/theme/tabs.ts | 34 +- frontend/src/ui/theme/theme.ts | 148 +- frontend/src/ui/theme/toolbar.ts | 98 +- frontend/src/ui/theme/utils.ts | 68 +- frontend/src/vendor/vlq/decode.ts | 58 +- 82 files changed, 10913 insertions(+), 10919 deletions(-) diff --git a/frontend/biome.json b/frontend/biome.json index c15aa6f..ff2b4fc 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -21,12 +21,6 @@ }, "formatter": { "enabled": true, - "indentStyle": "space", "lineWidth": 100 - }, - "javascript": { - "formatter": { - "quoteStyle": "single" - } } } diff --git a/frontend/package.json b/frontend/package.json index 325747c..8f27b0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,7 +44,7 @@ "start": "vite", "dev": "vite", "build": "vite build", - "format": "biome format ./src", + "format": "biome format ./src --write", "lint": "biome lint ./src" }, "browserslist": [ diff --git a/frontend/src/AppWrapper.tsx b/frontend/src/AppWrapper.tsx index 597bb12..044f781 100644 --- a/frontend/src/AppWrapper.tsx +++ b/frontend/src/AppWrapper.tsx @@ -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 ; - } - return ; + if (fatalErrorEncountered) { + return ; + } + return ; } diff --git a/frontend/src/data/eventsub-tests.ts b/frontend/src/data/eventsub-tests.ts index 318c302..ad914f8 100644 --- a/frontend/src/data/eventsub-tests.ts +++ b/frontend/src/data/eventsub-tests.ts @@ -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, + }, + }, }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index e5ae43f..15a5e52 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -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( - - - - - - - , + + + + + + + , ); diff --git a/frontend/src/lib/eventSub.ts b/frontend/src/lib/eventSub.ts index 29177d2..15ee0dc 100644 --- a/frontend/src/lib/eventSub.ts +++ b/frontend/src/lib/eventSub.ts @@ -1,388 +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 { - type: T; - subscription: EventSubSubscription; - event: Payload; + type: T; + subscription: EventSubSubscription; + event: Payload; } export type EventSubMessage = - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification< - EventSubNotificationType.CustomRewardRedemptionAdded, - ChannelRedemptionEventData - > - | TypedEventSubNotification< - EventSubNotificationType.CustomRewardRedemptionUpdated, - ChannelRedemptionEventData - > - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification> - | TypedEventSubNotification> - | TypedEventSubNotification - | TypedEventSubNotification> - | TypedEventSubNotification< - EventSubNotificationType.PredictionProgress, - PredictionEventData - > - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification - | TypedEventSubNotification< - EventSubNotificationType.SubscriptionGifted, - SubscriptionGiftedEventData - > - | TypedEventSubNotification< - EventSubNotificationType.SubscriptionWithMessage, - SubscriptionMessageEventData - > - | TypedEventSubNotification - | TypedEventSubNotification; + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardRedemptionAdded, + ChannelRedemptionEventData + > + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardRedemptionUpdated, + ChannelRedemptionEventData + > + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification> + | TypedEventSubNotification> + | TypedEventSubNotification + | TypedEventSubNotification> + | TypedEventSubNotification< + EventSubNotificationType.PredictionProgress, + PredictionEventData + > + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification + | TypedEventSubNotification< + EventSubNotificationType.SubscriptionGifted, + SubscriptionGiftedEventData + > + | TypedEventSubNotification< + EventSubNotificationType.SubscriptionWithMessage, + SubscriptionMessageEventData + > + | TypedEventSubNotification + | TypedEventSubNotification; 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 = - | ({ [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 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; + 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 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 extends PollBaseData { - started_at: string; - ends_at: string; + started_at: string; + ends_at: string; } export interface PollEndedEventData extends PollBaseData { - status: 'completed' | 'archived' | 'terminated'; - ended_at: string; + status: "completed" | "archived" | "terminated"; + ended_at: string; } -type PredictionColor = 'blue' | 'pink'; +type PredictionColor = "blue" | "pink"; interface Outcome { - id: string; - title: string; - color: Color; + id: string; + title: string; + color: Color; } interface RunningOutcome extends Outcome { - 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] | [B, A]; interface PredictionBaseData extends StreamEventData { - id: string; - title: string; - started_at: string; - outcomes: Running extends true - ? UnorderedTuple, RunningOutcome<'pink'>> - : UnorderedTuple, Outcome<'pink'>>; + id: string; + title: string; + started_at: string; + outcomes: Running extends true + ? UnorderedTuple, RunningOutcome<"pink">> + : UnorderedTuple, Outcome<"pink">>; } export interface PredictionEventData extends PredictionBaseData { - locks_at: string; + locks_at: string; } export interface PredictionLockedEventData extends PredictionBaseData { - locked_at: string; + locked_at: string; } export interface PredictionEndedEventData extends PredictionBaseData { - 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; } diff --git a/frontend/src/lib/extensions/extension.ts b/frontend/src/lib/extensions/extension.ts index aa928ec..1ce2907 100644 --- a/frontend/src/lib/extensions/extension.ts +++ b/frontend/src/lib/extensions/extension.ts @@ -1,11 +1,11 @@ -import type { ExtensionEntry } from '~/store/extensions/reducer'; +import type { ExtensionEntry } from "~/store/extensions/reducer"; import { - ExtensionStatus, - type ExtensionDependencies, - type ExtensionHostMessage, - type ExtensionHostCommand, - type ExtensionRunOptions, -} from './types'; + ExtensionStatus, + type ExtensionDependencies, + type ExtensionHostMessage, + type ExtensionHostCommand, + type ExtensionRunOptions, +} from "./types"; export const blankTemplate = (slug: string) => `// ==Extension== // @name ${slug} @@ -17,104 +17,104 @@ export const blankTemplate = (slug: string) => `// ==Extension== `; export class Extension extends EventTarget { - private readonly worker: Worker; - - private workerStatus = ExtensionStatus.GettingReady; - - private workerError?: ErrorEvent | Error; - - 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) => this.messageReceived(ev); - - // 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 messageReceived(ev: MessageEvent) { - 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 })); - } - - public get status() { - return this.workerStatus; - } - - public get error() { - return this.workerError; - } - - 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?'); - } - } - - stop() { - if (this.status === ExtensionStatus.Terminated) { - return; - } - this.worker.terminate(); - this.status = ExtensionStatus.Terminated; - } - - dispose() { - this.stop(); - } + private readonly worker: Worker; + + private workerStatus = ExtensionStatus.GettingReady; + + private workerError?: ErrorEvent | Error; + + 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) => this.messageReceived(ev); + + // 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 messageReceived(ev: MessageEvent) { + 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 })); + } + + public get status() { + return this.workerStatus; + } + + public get error() { + return this.workerError; + } + + 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?"); + } + } + + stop() { + if (this.status === ExtensionStatus.Terminated) { + return; + } + this.worker.terminate(); + this.status = ExtensionStatus.Terminated; + } + + dispose() { + this.stop(); + } } export default { Extension }; diff --git a/frontend/src/lib/extensions/metadata.ts b/frontend/src/lib/extensions/metadata.ts index 05e123e..5deb017 100644 --- a/frontend/src/lib/extensions/metadata.ts +++ b/frontend/src/lib/extensions/metadata.ts @@ -6,41 +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; - } + // 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 }; diff --git a/frontend/src/lib/extensions/sourceMap.ts b/frontend/src/lib/extensions/sourceMap.ts index 9472e6c..0e5595b 100644 --- a/frontend/src/lib/extensions/sourceMap.ts +++ b/frontend/src/lib/extensions/sourceMap.ts @@ -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 */ } diff --git a/frontend/src/lib/extensions/types.ts b/frontend/src/lib/extensions/types.ts index a46b4b2..c6d11c0 100644 --- a/frontend/src/lib/extensions/types.ts +++ b/frontend/src/lib/extensions/types.ts @@ -1,43 +1,43 @@ export interface ExtensionDependencies { - kilovolt: { - address: string; - password?: string; - }; + kilovolt: { + address: string; + password?: string; + }; } export interface ExtensionOptions { - enabled: boolean; + enabled: boolean; } // 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; } diff --git a/frontend/src/lib/extensions/workers/extensionHost.ts b/frontend/src/lib/extensions/workers/extensionHost.ts index effbc2c..e012210 100644 --- a/frontend/src/lib/extensions/workers/extensionHost.ts +++ b/frontend/src/lib/extensions/workers/extensionHost.ts @@ -1,10 +1,10 @@ -import Kilovolt from '@strimertul/kilovolt-client'; -import ts from 'typescript'; -import { type ExtensionHostCommand, type ExtensionHostMessage, ExtensionStatus } from '../types'; -import { type 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); + postMessage(message, transfer); async function ExtensionFunction(_kv: Kilovolt) {} @@ -14,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 (...args: { toString(): string }[]) => { - const message = args.join(' '); - void kv.putJSON('strimertul/@log', { - level, - message, - data: { - extension: name, - }, - }); - }; + // 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); } -addEventListener('message', async (ev: MessageEvent) => { - const cmd = ev.data; - switch (cmd.kind) { - case 'arguments': { - name = cmd.name; +addEventListener("message", async (ev: MessageEvent) => { + 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; + } }); diff --git a/frontend/src/lib/extensions/workers/tsconfig.json b/frontend/src/lib/extensions/workers/tsconfig.json index 916b48d..9aa90fd 100644 --- a/frontend/src/lib/extensions/workers/tsconfig.json +++ b/frontend/src/lib/extensions/workers/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../../../tsconfig.json", - "compilerOptions": { - "lib": ["es2019", "WebWorker"] - } + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "lib": ["es2019", "WebWorker"] + } } diff --git a/frontend/src/lib/react.ts b/frontend/src/lib/react.ts index aa6cef8..e9dac9f 100644 --- a/frontend/src/lib/react.ts +++ b/frontend/src/lib/react.ts @@ -1,82 +1,82 @@ -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 { 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'; + 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 = useKilovoltClient(); - const [data, setData] = useState(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); - }; - }, [key]); - - return data; + const client = useKilovoltClient(); + const [data, setData] = useState(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); + }; + }, [key]); + + return data; } export function useLiveKey(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({ - key, - selector, - getter, - setter, - asyncSetter, + key, + selector, + getter, + setter, + asyncSetter, }: { - key: string; - selector: (state: Draft) => T; - getter: AsyncThunk; - setter: AsyncThunk; - asyncSetter: ActionCreatorWithOptionalPayload; + key: string; + selector: (state: Draft) => T; + getter: AsyncThunk; + setter: AsyncThunk; + asyncSetter: ActionCreatorWithOptionalPayload; }): [T, AsyncThunk, 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, - }, - ]; + 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, + }, + ]; } const interval = 5000; @@ -88,48 +88,48 @@ const interval = 5000; * @returns Reactive status */ export function useTimedStatus(status: RequestStatus | null): RequestStatus | null { - const [localStatus, setlocalStatus] = useState(status); - const [maxTime, setMaxTime] = useState(0); - - 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; + const [localStatus, setlocalStatus] = useState(status); + const [maxTime, setMaxTime] = useState(0); + + 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 = 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; + 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: useTimedStatus, - useUserPoints, + useModule, + useStatus: useTimedStatus, + useUserPoints, }; diff --git a/frontend/src/lib/slug.ts b/frontend/src/lib/slug.ts index 9a68967..274548a 100644 --- a/frontend/src/lib/slug.ts +++ b/frontend/src/lib/slug.ts @@ -1,16 +1,16 @@ const adjectives = - 'abandoned|able|absolute|adorable|adventurous|academic|acceptable|acclaimed|accomplished|accurate|aching|acidic|acrobatic|active|actual|adept|admirable|admired|adolescent|adorable|adored|advanced|afraid|affectionate|aged|aggravating|aggressive|agile|agitated|agonizing|agreeable|ajar|alarmed|alarming|alert|alienated|alive|all|altruistic|amazing|ambitious|ample|amused|amusing|anchored|ancient|angelic|angry|anguished|animated|annual|another|antique|anxious|any|apprehensive|appropriate|apt|arctic|arid|aromatic|artistic|ashamed|assured|astonishing|athletic|attached|attentive|attractive|austere|authentic|authorized|automatic|avaricious|average|aware|awesome|awful|awkward|babyish|bad|back|baggy|bare|barren|basic|beautiful|belated|beloved|beneficial|better|best|bewitched|big|big-hearted|biodegradable|bite-sized|bitter|black|black-and-white|bland|blank|blaring|bleak|blind|blissful|blond|blue|blushing|bogus|boiling|bold|bony|boring|bossy|both|bouncy|bountiful|bowed|brave|breakable|brief|bright|brilliant|brisk|broken|bronze|brown|bruised|bubbly|bulky|bumpy|buoyant|burdensome|burly|bustling|busy|buttery|buzzing|calculating|calm|candid|canine|capital|carefree|careful|careless|caring|cautious|cavernous|celebrated|charming|cheap|cheerful|cheery|chief|chilly|chubby|circular|classic|clean|clear|clear-cut|clever|close|closed|cloudy|clueless|clumsy|cluttered|coarse|cold|colorful|colorless|colossal|comfortable|common|compassionate|competent|complete|complex|complicated|composed|concerned|concrete|confused|conscious|considerate|constant|content|conventional|cooked|cool|cooperative|coordinated|corny|corrupt|costly|courageous|courteous|crafty|crazy|creamy|creative|creepy|criminal|crisp|critical|crooked|crowded|cruel|crushing|cuddly|cultivated|cultured|cumbersome|curly|curvy|cute|cylindrical|damaged|damp|dangerous|dapper|daring|darling|dark|dazzling|dead|deadly|deafening|dear|dearest|decent|decimal|decisive|deep|defenseless|defensive|defiant|deficient|definite|definitive|delayed|delectable|delicious|delightful|delirious|demanding|dense|dental|dependable|dependent|descriptive|deserted|detailed|determined|devoted|different|difficult|digital|diligent|dim|dimpled|dimwitted|direct|disastrous|discrete|disfigured|disgusting|disloyal|dismal|distant|downright|dreary|dirty|disguised|dishonest|dismal|distant|distinct|distorted|dizzy|dopey|doting|double|downright|drab|drafty|dramatic|dreary|droopy|dry|dual|dull|dutiful|each|eager|earnest|early|easy|easy-going|ecstatic|edible|educated|elaborate|elastic|elated|elderly|electric|elegant|elementary|elliptical|embarrassed|embellished|eminent|emotional|empty|enchanted|enchanting|energetic|enlightened|enormous|enraged|entire|envious|equal|equatorial|essential|esteemed|ethical|euphoric|even|evergreen|everlasting|every|evil|exalted|excellent|exemplary|exhausted|excitable|excited|exciting|exotic|expensive|experienced|expert|extraneous|extroverted|extra-large|extra-small|fabulous|failing|faint|fair|faithful|fake|false|familiar|famous|fancy|fantastic|far|faraway|far-flung|far-off|fast|fat|fatal|fatherly|favorable|favorite|fearful|fearless|feisty|feline|female|feminine|few|fickle|filthy|fine|finished|firm|first|firsthand|fitting|fixed|flaky|flamboyant|flashy|flat|flawed|flawless|flickering|flimsy|flippant|flowery|fluffy|fluid|flustered|focused|fond|foolhardy|foolish|forceful|forked|formal|forsaken|forthright|fortunate|fragrant|frail|frank|frayed|free|French|fresh|frequent|friendly|frightened|frightening|frigid|frilly|frizzy|frivolous|front|frosty|frozen|frugal|fruitful|full|fumbling|functional|funny|fussy|fuzzy|gargantuan|gaseous|general|generous|gentle|genuine|giant|giddy|gigantic|gifted|giving|glamorous|glaring|glass|gleaming|gleeful|glistening|glittering|gloomy|glorious|glossy|glum|golden|good|good-natured|gorgeous|graceful|gracious|grand|grandiose|granular|grateful|grave|gray|great|greedy|green|gregarious|grim|grimy|gripping|grizzled|gross|grotesque|grouchy|grounded|growing|growling|grown|grubby|gruesome|grumpy|guilty|gullible|gummy|hairy|half|handmade|handsome|handy|happy|happy-go-lucky|hard|hard-to-find|harmful|harmless|harmonious|harsh|hasty|hateful|haunting|healthy|heartfelt|hearty|heavenly|heavy|hefty|helpful|helpless|hidden|hideous|high|high-level|hilarious|hoarse|hollow|homely|honest|honorable|honored|hopeful|horrible|hospitable|hot|huge|humble|humiliating|humming|humongous|hungry|hurtful|husky|icky|icy|ideal|idealistic|identical|idle|idiotic|idolized|ignorant|ill|illegal|ill-fated|ill-informed|illiterate|illustrious|imaginary|imaginative|immaculate|immaterial|immediate|immense|impassioned|impeccable|impartial|imperfect|imperturbable|impish|impolite|important|impossible|impractical|impressionable|impressive|improbable|impure|inborn|incomparable|incompatible|incomplete|inconsequential|incredible|indelible|inexperienced|indolent|infamous|infantile|infatuated|inferior|infinite|informal|innocent|insecure|insidious|insignificant|insistent|instructive|insubstantial|intelligent|intent|intentional|interesting|internal|international|intrepid|ironclad|irresponsible|irritating|itchy|jaded|jagged|jam-packed|jaunty|jealous|jittery|joint|jolly|jovial|joyful|joyous|jubilant|judicious|juicy|jumbo|junior|jumpy|juvenile|kaleidoscopic|keen|key|kind|kindhearted|kindly|klutzy|knobby|knotty|knowledgeable|knowing|known|kooky|kosher|lame|lanky|large|last|lasting|late|lavish|lawful|lazy|leading|lean|leafy|left|legal|legitimate|light|lighthearted|likable|likely|limited|limp|limping|linear|lined|liquid|little|live|lively|livid|loathsome|lone|lonely|long|long-term|loose|lopsided|lost|loud|lovable|lovely|loving|low|loyal|lucky|lumbering|luminous|lumpy|lustrous|luxurious|mad|made-up|magnificent|majestic|major|male|mammoth|married|marvelous|masculine|massive|mature|meager|mealy|mean|measly|meaty|medical|mediocre|medium|meek|mellow|melodic|memorable|menacing|merry|messy|metallic|mild|milky|mindless|miniature|minor|minty|miserable|miserly|misguided|misty|mixed|modern|modest|moist|monstrous|monthly|monumental|moral|mortified|motherly|motionless|mountainous|muddy|muffled|multicolored|mundane|murky|mushy|musty|muted|mysterious|naive|narrow|nasty|natural|naughty|nautical|near|neat|necessary|needy|negative|neglected|negligible|neighboring|nervous|new|next|nice|nifty|nimble|nippy|nocturnal|noisy|nonstop|normal|notable|noted|noteworthy|novel|noxious|numb|nutritious|nutty|obedient|obese|oblong|oily|oblong|obvious|occasional|odd|oddball|offbeat|offensive|official|old|old-fashioned|only|open|optimal|optimistic|opulent|orange|orderly|organic|ornate|ornery|ordinary|original|other|our|outlying|outgoing|outlandish|outrageous|outstanding|oval|overcooked|overdue|overjoyed|overlooked|palatable|pale|paltry|parallel|parched|partial|passionate|past|pastel|peaceful|peppery|perfect|perfumed|periodic|perky|personal|pertinent|pesky|pessimistic|petty|phony|physical|piercing|pink|pitiful|plain|plaintive|plastic|playful|pleasant|pleased|pleasing|plump|plush|polished|polite|political|pointed|pointless|poised|poor|popular|portly|posh|positive|possible|potable|powerful|powerless|practical|precious|present|prestigious|pretty|precious|previous|pricey|prickly|primary|prime|pristine|private|prize|probable|productive|profitable|profuse|proper|proud|prudent|punctual|pungent|puny|pure|purple|pushy|putrid|puzzled|puzzling|quaint|qualified|quarrelsome|quarterly|queasy|querulous|questionable|quick|quick-witted|quiet|quintessential|quirky|quixotic|quizzical|radiant|ragged|rapid|rare|rash|raw|recent|reckless|rectangular|ready|real|realistic|reasonable|red|reflecting|regal|regular|reliable|relieved|remarkable|remorseful|remote|repentant|required|respectful|responsible|repulsive|revolving|rewarding|rich|rigid|right|ringed|ripe|roasted|robust|rosy|rotating|rotten|rough|round|rowdy|royal|rubbery|rundown|ruddy|rude|runny|rural|rusty|sad|safe|salty|same|sandy|sane|sarcastic|sardonic|satisfied|scaly|scarce|scared|scary|scented|scholarly|scientific|scornful|scratchy|scrawny|second|secondary|second-hand|secret|self-assured|self-reliant|selfish|sentimental|separate|serene|serious|serpentine|several|severe|shabby|shadowy|shady|shallow|shameful|shameless|sharp|shimmering|shiny|shocked|shocking|shoddy|short|short-term|showy|shrill|shy|sick|silent|silky|silly|silver|similar|simple|simplistic|sinful|single|sizzling|skeletal|skinny|sleepy|slight|slim|slimy|slippery|slow|slushy|small|smart|smoggy|smooth|smug|snappy|snarling|sneaky|sniveling|snoopy|sociable|soft|soggy|solid|somber|some|spherical|sophisticated|sore|sorrowful|soulful|soupy|sour|Spanish|sparkling|sparse|specific|spectacular|speedy|spicy|spiffy|spirited|spiteful|splendid|spotless|spotted|spry|square|squeaky|squiggly|stable|staid|stained|stale|standard|starchy|stark|starry|steep|sticky|stiff|stimulating|stingy|stormy|straight|strange|steel|strict|strident|striking|striped|strong|studious|stunning|stupendous|stupid|sturdy|stylish|subdued|submissive|substantial|subtle|suburban|sudden|sugary|sunny|super|superb|superficial|superior|supportive|sure-footed|surprised|suspicious|svelte|sweaty|sweet|sweltering|swift|sympathetic|tall|talkative|tame|tan|tangible|tart|tasty|tattered|taut|tedious|teeming|tempting|tender|tense|tepid|terrible|terrific|testy|thankful|that|these|thick|thin|third|thirsty|this|thorough|thorny|those|thoughtful|threadbare|thrifty|thunderous|tidy|tight|timely|tinted|tiny|tired|torn|total|tough|traumatic|treasured|tremendous|tragic|trained|tremendous|triangular|tricky|trifling|trim|trivial|troubled|true|trusting|trustworthy|trusty|truthful|tubby|turbulent|twin|ugly|ultimate|unacceptable|unaware|uncomfortable|uncommon|unconscious|understated|unequaled|uneven|unfinished|unfit|unfolded|unfortunate|unhappy|unhealthy|uniform|unimportant|unique|united|unkempt|unknown|unlawful|unlined|unlucky|unnatural|unpleasant|unrealistic|unripe|unruly|unselfish|unsightly|unsteady|unsung|untidy|untimely|untried|untrue|unused|unusual|unwelcome|unwieldy|unwilling|unwitting|unwritten|upbeat|upright|upset|urban|usable|used|useful|useless|utilized|utter|vacant|vague|vain|valid|valuable|vapid|variable|vast|velvety|venerated|vengeful|verifiable|vibrant|vicious|victorious|vigilant|vigorous|villainous|violet|violent|virtual|virtuous|visible|vital|vivacious|vivid|voluminous|wan|warlike|warm|warmhearted|warped|wary|wasteful|watchful|waterlogged|watery|wavy|wealthy|weak|weary|webbed|wee|weekly|weepy|weighty|weird|welcome|well-documented|well-groomed|well-informed|well-lit|well-made|well-off|well-to-do|well-worn|wet|which|whimsical|whirlwind|whispered|white|whole|whopping|wicked|wide|wide-eyed|wiggly|wild|willing|wilted|winding|windy|winged|wiry|wise|witty|wobbly|woeful|wonderful|wooden|woozy|wordy|worldly|worn|worried|worrisome|worse|worst|worthless|worthwhile|worthy|wrathful|wretched|writhing|wrong|wry|yawning|yearly|yellow|yellowish|young|youthful|yummy|zany|zealous|zesty|zigzag'.split( - '|', - ); + "abandoned|able|absolute|adorable|adventurous|academic|acceptable|acclaimed|accomplished|accurate|aching|acidic|acrobatic|active|actual|adept|admirable|admired|adolescent|adorable|adored|advanced|afraid|affectionate|aged|aggravating|aggressive|agile|agitated|agonizing|agreeable|ajar|alarmed|alarming|alert|alienated|alive|all|altruistic|amazing|ambitious|ample|amused|amusing|anchored|ancient|angelic|angry|anguished|animated|annual|another|antique|anxious|any|apprehensive|appropriate|apt|arctic|arid|aromatic|artistic|ashamed|assured|astonishing|athletic|attached|attentive|attractive|austere|authentic|authorized|automatic|avaricious|average|aware|awesome|awful|awkward|babyish|bad|back|baggy|bare|barren|basic|beautiful|belated|beloved|beneficial|better|best|bewitched|big|big-hearted|biodegradable|bite-sized|bitter|black|black-and-white|bland|blank|blaring|bleak|blind|blissful|blond|blue|blushing|bogus|boiling|bold|bony|boring|bossy|both|bouncy|bountiful|bowed|brave|breakable|brief|bright|brilliant|brisk|broken|bronze|brown|bruised|bubbly|bulky|bumpy|buoyant|burdensome|burly|bustling|busy|buttery|buzzing|calculating|calm|candid|canine|capital|carefree|careful|careless|caring|cautious|cavernous|celebrated|charming|cheap|cheerful|cheery|chief|chilly|chubby|circular|classic|clean|clear|clear-cut|clever|close|closed|cloudy|clueless|clumsy|cluttered|coarse|cold|colorful|colorless|colossal|comfortable|common|compassionate|competent|complete|complex|complicated|composed|concerned|concrete|confused|conscious|considerate|constant|content|conventional|cooked|cool|cooperative|coordinated|corny|corrupt|costly|courageous|courteous|crafty|crazy|creamy|creative|creepy|criminal|crisp|critical|crooked|crowded|cruel|crushing|cuddly|cultivated|cultured|cumbersome|curly|curvy|cute|cylindrical|damaged|damp|dangerous|dapper|daring|darling|dark|dazzling|dead|deadly|deafening|dear|dearest|decent|decimal|decisive|deep|defenseless|defensive|defiant|deficient|definite|definitive|delayed|delectable|delicious|delightful|delirious|demanding|dense|dental|dependable|dependent|descriptive|deserted|detailed|determined|devoted|different|difficult|digital|diligent|dim|dimpled|dimwitted|direct|disastrous|discrete|disfigured|disgusting|disloyal|dismal|distant|downright|dreary|dirty|disguised|dishonest|dismal|distant|distinct|distorted|dizzy|dopey|doting|double|downright|drab|drafty|dramatic|dreary|droopy|dry|dual|dull|dutiful|each|eager|earnest|early|easy|easy-going|ecstatic|edible|educated|elaborate|elastic|elated|elderly|electric|elegant|elementary|elliptical|embarrassed|embellished|eminent|emotional|empty|enchanted|enchanting|energetic|enlightened|enormous|enraged|entire|envious|equal|equatorial|essential|esteemed|ethical|euphoric|even|evergreen|everlasting|every|evil|exalted|excellent|exemplary|exhausted|excitable|excited|exciting|exotic|expensive|experienced|expert|extraneous|extroverted|extra-large|extra-small|fabulous|failing|faint|fair|faithful|fake|false|familiar|famous|fancy|fantastic|far|faraway|far-flung|far-off|fast|fat|fatal|fatherly|favorable|favorite|fearful|fearless|feisty|feline|female|feminine|few|fickle|filthy|fine|finished|firm|first|firsthand|fitting|fixed|flaky|flamboyant|flashy|flat|flawed|flawless|flickering|flimsy|flippant|flowery|fluffy|fluid|flustered|focused|fond|foolhardy|foolish|forceful|forked|formal|forsaken|forthright|fortunate|fragrant|frail|frank|frayed|free|French|fresh|frequent|friendly|frightened|frightening|frigid|frilly|frizzy|frivolous|front|frosty|frozen|frugal|fruitful|full|fumbling|functional|funny|fussy|fuzzy|gargantuan|gaseous|general|generous|gentle|genuine|giant|giddy|gigantic|gifted|giving|glamorous|glaring|glass|gleaming|gleeful|glistening|glittering|gloomy|glorious|glossy|glum|golden|good|good-natured|gorgeous|graceful|gracious|grand|grandiose|granular|grateful|grave|gray|great|greedy|green|gregarious|grim|grimy|gripping|grizzled|gross|grotesque|grouchy|grounded|growing|growling|grown|grubby|gruesome|grumpy|guilty|gullible|gummy|hairy|half|handmade|handsome|handy|happy|happy-go-lucky|hard|hard-to-find|harmful|harmless|harmonious|harsh|hasty|hateful|haunting|healthy|heartfelt|hearty|heavenly|heavy|hefty|helpful|helpless|hidden|hideous|high|high-level|hilarious|hoarse|hollow|homely|honest|honorable|honored|hopeful|horrible|hospitable|hot|huge|humble|humiliating|humming|humongous|hungry|hurtful|husky|icky|icy|ideal|idealistic|identical|idle|idiotic|idolized|ignorant|ill|illegal|ill-fated|ill-informed|illiterate|illustrious|imaginary|imaginative|immaculate|immaterial|immediate|immense|impassioned|impeccable|impartial|imperfect|imperturbable|impish|impolite|important|impossible|impractical|impressionable|impressive|improbable|impure|inborn|incomparable|incompatible|incomplete|inconsequential|incredible|indelible|inexperienced|indolent|infamous|infantile|infatuated|inferior|infinite|informal|innocent|insecure|insidious|insignificant|insistent|instructive|insubstantial|intelligent|intent|intentional|interesting|internal|international|intrepid|ironclad|irresponsible|irritating|itchy|jaded|jagged|jam-packed|jaunty|jealous|jittery|joint|jolly|jovial|joyful|joyous|jubilant|judicious|juicy|jumbo|junior|jumpy|juvenile|kaleidoscopic|keen|key|kind|kindhearted|kindly|klutzy|knobby|knotty|knowledgeable|knowing|known|kooky|kosher|lame|lanky|large|last|lasting|late|lavish|lawful|lazy|leading|lean|leafy|left|legal|legitimate|light|lighthearted|likable|likely|limited|limp|limping|linear|lined|liquid|little|live|lively|livid|loathsome|lone|lonely|long|long-term|loose|lopsided|lost|loud|lovable|lovely|loving|low|loyal|lucky|lumbering|luminous|lumpy|lustrous|luxurious|mad|made-up|magnificent|majestic|major|male|mammoth|married|marvelous|masculine|massive|mature|meager|mealy|mean|measly|meaty|medical|mediocre|medium|meek|mellow|melodic|memorable|menacing|merry|messy|metallic|mild|milky|mindless|miniature|minor|minty|miserable|miserly|misguided|misty|mixed|modern|modest|moist|monstrous|monthly|monumental|moral|mortified|motherly|motionless|mountainous|muddy|muffled|multicolored|mundane|murky|mushy|musty|muted|mysterious|naive|narrow|nasty|natural|naughty|nautical|near|neat|necessary|needy|negative|neglected|negligible|neighboring|nervous|new|next|nice|nifty|nimble|nippy|nocturnal|noisy|nonstop|normal|notable|noted|noteworthy|novel|noxious|numb|nutritious|nutty|obedient|obese|oblong|oily|oblong|obvious|occasional|odd|oddball|offbeat|offensive|official|old|old-fashioned|only|open|optimal|optimistic|opulent|orange|orderly|organic|ornate|ornery|ordinary|original|other|our|outlying|outgoing|outlandish|outrageous|outstanding|oval|overcooked|overdue|overjoyed|overlooked|palatable|pale|paltry|parallel|parched|partial|passionate|past|pastel|peaceful|peppery|perfect|perfumed|periodic|perky|personal|pertinent|pesky|pessimistic|petty|phony|physical|piercing|pink|pitiful|plain|plaintive|plastic|playful|pleasant|pleased|pleasing|plump|plush|polished|polite|political|pointed|pointless|poised|poor|popular|portly|posh|positive|possible|potable|powerful|powerless|practical|precious|present|prestigious|pretty|precious|previous|pricey|prickly|primary|prime|pristine|private|prize|probable|productive|profitable|profuse|proper|proud|prudent|punctual|pungent|puny|pure|purple|pushy|putrid|puzzled|puzzling|quaint|qualified|quarrelsome|quarterly|queasy|querulous|questionable|quick|quick-witted|quiet|quintessential|quirky|quixotic|quizzical|radiant|ragged|rapid|rare|rash|raw|recent|reckless|rectangular|ready|real|realistic|reasonable|red|reflecting|regal|regular|reliable|relieved|remarkable|remorseful|remote|repentant|required|respectful|responsible|repulsive|revolving|rewarding|rich|rigid|right|ringed|ripe|roasted|robust|rosy|rotating|rotten|rough|round|rowdy|royal|rubbery|rundown|ruddy|rude|runny|rural|rusty|sad|safe|salty|same|sandy|sane|sarcastic|sardonic|satisfied|scaly|scarce|scared|scary|scented|scholarly|scientific|scornful|scratchy|scrawny|second|secondary|second-hand|secret|self-assured|self-reliant|selfish|sentimental|separate|serene|serious|serpentine|several|severe|shabby|shadowy|shady|shallow|shameful|shameless|sharp|shimmering|shiny|shocked|shocking|shoddy|short|short-term|showy|shrill|shy|sick|silent|silky|silly|silver|similar|simple|simplistic|sinful|single|sizzling|skeletal|skinny|sleepy|slight|slim|slimy|slippery|slow|slushy|small|smart|smoggy|smooth|smug|snappy|snarling|sneaky|sniveling|snoopy|sociable|soft|soggy|solid|somber|some|spherical|sophisticated|sore|sorrowful|soulful|soupy|sour|Spanish|sparkling|sparse|specific|spectacular|speedy|spicy|spiffy|spirited|spiteful|splendid|spotless|spotted|spry|square|squeaky|squiggly|stable|staid|stained|stale|standard|starchy|stark|starry|steep|sticky|stiff|stimulating|stingy|stormy|straight|strange|steel|strict|strident|striking|striped|strong|studious|stunning|stupendous|stupid|sturdy|stylish|subdued|submissive|substantial|subtle|suburban|sudden|sugary|sunny|super|superb|superficial|superior|supportive|sure-footed|surprised|suspicious|svelte|sweaty|sweet|sweltering|swift|sympathetic|tall|talkative|tame|tan|tangible|tart|tasty|tattered|taut|tedious|teeming|tempting|tender|tense|tepid|terrible|terrific|testy|thankful|that|these|thick|thin|third|thirsty|this|thorough|thorny|those|thoughtful|threadbare|thrifty|thunderous|tidy|tight|timely|tinted|tiny|tired|torn|total|tough|traumatic|treasured|tremendous|tragic|trained|tremendous|triangular|tricky|trifling|trim|trivial|troubled|true|trusting|trustworthy|trusty|truthful|tubby|turbulent|twin|ugly|ultimate|unacceptable|unaware|uncomfortable|uncommon|unconscious|understated|unequaled|uneven|unfinished|unfit|unfolded|unfortunate|unhappy|unhealthy|uniform|unimportant|unique|united|unkempt|unknown|unlawful|unlined|unlucky|unnatural|unpleasant|unrealistic|unripe|unruly|unselfish|unsightly|unsteady|unsung|untidy|untimely|untried|untrue|unused|unusual|unwelcome|unwieldy|unwilling|unwitting|unwritten|upbeat|upright|upset|urban|usable|used|useful|useless|utilized|utter|vacant|vague|vain|valid|valuable|vapid|variable|vast|velvety|venerated|vengeful|verifiable|vibrant|vicious|victorious|vigilant|vigorous|villainous|violet|violent|virtual|virtuous|visible|vital|vivacious|vivid|voluminous|wan|warlike|warm|warmhearted|warped|wary|wasteful|watchful|waterlogged|watery|wavy|wealthy|weak|weary|webbed|wee|weekly|weepy|weighty|weird|welcome|well-documented|well-groomed|well-informed|well-lit|well-made|well-off|well-to-do|well-worn|wet|which|whimsical|whirlwind|whispered|white|whole|whopping|wicked|wide|wide-eyed|wiggly|wild|willing|wilted|winding|windy|winged|wiry|wise|witty|wobbly|woeful|wonderful|wooden|woozy|wordy|worldly|worn|worried|worrisome|worse|worst|worthless|worthwhile|worthy|wrathful|wretched|writhing|wrong|wry|yawning|yearly|yellow|yellowish|young|youthful|yummy|zany|zealous|zesty|zigzag".split( + "|", + ); const nouns = - 'people|history|way|art|world|information|map|two|family|government|health|system|computer|meat|year|thanks|music|person|reading|method|data|food|understanding|theory|law|bird|literature|problem|software|control|knowledge|power|ability|economics|love|internet|television|science|library|nature|fact|product|idea|temperature|investment|area|society|activity|story|industry|media|thing|oven|community|definition|safety|quality|development|language|management|player|variety|video|week|security|country|exam|movie|organization|equipment|physics|analysis|policy|series|thought|basis|boyfriend|direction|strategy|technology|army|camera|freedom|paper|environment|child|instance|month|truth|marketing|university|writing|article|department|difference|goal|news|audience|fishing|growth|income|marriage|user|combination|failure|meaning|medicine|philosophy|teacher|communication|night|chemistry|disease|disk|energy|nation|road|role|soup|advertising|location|success|addition|apartment|education|math|moment|painting|politics|attention|decision|event|property|shopping|student|wood|competition|distribution|entertainment|office|population|president|unit|category|cigarette|context|introduction|opportunity|performance|driver|flight|length|magazine|newspaper|relationship|teaching|cell|dealer|finding|lake|member|message|phone|scene|appearance|association|concept|customer|death|discussion|housing|inflation|insurance|mood|woman|advice|blood|effort|expression|importance|opinion|payment|reality|responsibility|situation|skill|statement|wealth|application|city|county|depth|estate|foundation|grandmother|heart|perspective|photo|recipe|studio|topic|collection|depression|imagination|passion|percentage|resource|setting|ad|agency|college|connection|criticism|debt|description|memory|patience|secretary|solution|administration|aspect|attitude|director|personality|psychology|recommendation|response|selection|storage|version|alcohol|argument|complaint|contract|emphasis|highway|loss|membership|possession|preparation|steak|union|agreement|cancer|currency|employment|engineering|entry|interaction|mixture|preference|region|republic|tradition|virus|actor|classroom|delivery|device|difficulty|drama|election|engine|football|guidance|hotel|owner|priority|protection|suggestion|tension|variation|anxiety|atmosphere|awareness|bath|bread|candidate|climate|comparison|confusion|construction|elevator|emotion|employee|employer|guest|height|leadership|mall|manager|operation|recording|sample|transportation|charity|cousin|disaster|editor|efficiency|excitement|extent|feedback|guitar|homework|leader|mom|outcome|permission|presentation|promotion|reflection|refrigerator|resolution|revenue|session|singer|tennis|basket|bonus|cabinet|childhood|church|clothes|coffee|dinner|drawing|hair|hearing|initiative|judgment|lab|measurement|mode|mud|orange|poetry|police|possibility|procedure|queen|ratio|relation|restaurant|satisfaction|sector|signature|significance|song|tooth|town|vehicle|volume|wife|accident|airport|appointment|arrival|assumption|baseball|chapter|committee|conversation|database|enthusiasm|error|explanation|farmer|gate|girl|hall|historian|hospital|injury|instruction|maintenance|manufacturer|meal|perception|pie|poem|presence|proposal|reception|replacement|revolution|river|son|speech|tea|village|warning|winner|worker|writer|assistance|breath|buyer|chest|chocolate|conclusion|contribution|cookie|courage|dad|desk|drawer|establishment|examination|garbage|grocery|honey|impression|improvement|independence|insect|inspection|inspector|king|ladder|menu|penalty|piano|potato|profession|professor|quantity|reaction|requirement|salad|sister|supermarket|tongue|weakness|wedding|affair|ambition|analyst|apple|assignment|assistant|bathroom|bedroom|beer|birthday|celebration|championship|cheek|client|consequence|departure|diamond|dirt|ear|fortune|friendship|funeral|gene|girlfriend|hat|indication|intention|lady|midnight|negotiation|obligation|passenger|pizza|platform|poet|pollution|recognition|reputation|shirt|sir|speaker|stranger|surgery|sympathy|tale|throat|trainer|uncle|youth|time|work|film|water|money|example|while|business|study|game|life|form|air|day|place|number|part|field|fish|back|process|heat|hand|experience|job|book|end|point|type|home|economy|value|body|market|guide|interest|state|radio|course|company|price|size|card|list|mind|trade|line|care|group|risk|word|fat|force|key|light|training|name|school|top|amount|level|order|practice|research|sense|service|piece|web|boss|sport|fun|house|page|term|test|answer|sound|focus|matter|kind|soil|board|oil|picture|access|garden|range|rate|reason|future|site|demand|exercise|image|case|cause|coast|action|age|bad|boat|record|result|section|building|mouse|cash|class|nothing|period|plan|store|tax|side|subject|space|rule|stock|weather|chance|figure|man|model|source|beginning|earth|program|chicken|design|feature|head|material|purpose|question|rock|salt|act|birth|car|dog|object|scale|sun|note|profit|rent|speed|style|war|bank|craft|half|inside|outside|standard|bus|exchange|eye|fire|position|pressure|stress|advantage|benefit|box|frame|issue|step|cycle|face|item|metal|paint|review|room|screen|structure|view|account|ball|discipline|medium|share|balance|bit|black|bottom|choice|gift|impact|machine|shape|tool|wind|address|average|career|culture|morning|pot|sign|table|task|condition|contact|credit|egg|hope|ice|network|north|square|attempt|date|effect|link|post|star|voice|capital|challenge|friend|self|shot|brush|couple|debate|exit|front|function|lack|living|plant|plastic|spot|summer|taste|theme|track|wing|brain|button|click|desire|foot|gas|influence|notice|rain|wall|base|damage|distance|feeling|pair|savings|staff|sugar|target|text|animal|author|budget|discount|file|ground|lesson|minute|officer|phase|reference|register|sky|stage|stick|title|trouble|bowl|bridge|campaign|character|club|edge|evidence|fan|letter|lock|maximum|novel|option|pack|park|plenty|quarter|skin|sort|weight|baby|background|carry|dish|factor|fruit|glass|joint|master|muscle|red|strength|traffic|trip|vegetable|appeal|chart|gear|ideal|kitchen|land|log|mother|net|party|principle|relative|sale|season|signal|spirit|street|tree|wave|belt|bench|commission|copy|drop|minimum|path|progress|project|sea|south|status|stuff|ticket|tour|angle|blue|breakfast|confidence|daughter|degree|doctor|dot|dream|duty|essay|father|fee|finance|hour|juice|limit|luck|milk|mouth|peace|pipe|seat|stable|storm|substance|team|trick|afternoon|bat|beach|blank|catch|chain|consideration|cream|crew|detail|gold|interview|kid|mark|match|mission|pain|pleasure|score|screw|sex|shop|shower|suit|tone|window|agent|band|block|bone|calendar|cap|coat|contest|corner|court|cup|district|door|east|finger|garage|guarantee|hole|hook|implement|layer|lecture|lie|manner|meeting|nose|parking|partner|profile|respect|rice|routine|schedule|swimming|telephone|tip|winter|airline|bag|battle|bed|bill|bother|cake|code|curve|designer|dimension|dress|ease|emergency|evening|extension|farm|fight|gap|grade|holiday|horror|horse|host|husband|loan|mistake|mountain|nail|noise|occasion|package|patient|pause|phrase|proof|race|relief|sand|sentence|shoulder|smoke|stomach|string|tourist|towel|vacation|west|wheel|wine|arm|aside|associate|bet|blow|border|branch|breast|brother|buddy|bunch|chip|coach|cross|document|draft|dust|expert|floor|god|golf|habit|iron|judge|knife|landscape|league|mail|mess|native|opening|parent|pattern|pin|pool|pound|request|salary|shame|shelter|shoe|silver|tackle|tank|trust|assist|bake|bar|bell|bike|blame|boy|brick|chair|closet|clue|collar|comment|conference|devil|diet|fear|fuel|glove|jacket|lunch|monitor|mortgage|nurse|pace|panic|peak|plane|reward|row|sandwich|shock|spite|spray|surprise|till|transition|weekend|welcome|yard|alarm|bend|bicycle|bite|blind|bottle|cable|candle|clerk|cloud|concert|counter|flower|grandfather|harm|knee|lawyer|leather|load|mirror|neck|pension|plate|purple|ruin|ship|skirt|slice|snow|specialist|stroke|switch|trash|tune|zone|anger|award|bid|bitter|boot|bug|camp|candy|carpet|cat|champion|channel|clock|comfort|cow|crack|engineer|entrance|fault|grass|guy|hell|highlight|incident|island|joke|jury|leg|lip|mate|motor|nerve|passage|pen|pride|priest|prize|promise|resident|resort|ring|roof|rope|sail|scheme|script|sock|station|toe|tower|truck|witness|a|you|it|can|will|if|one|many|most|other|use|make|good|look|help|go|great|being|few|might|still|public|read|keep|start|give|human|local|general|she|specific|long|play|feel|high|tonight|put|common|set|change|simple|past|big|possible|particular|today|major|personal|current|national|cut|natural|physical|show|try|check|second|call|move|pay|let|increase|single|individual|turn|ask|buy|guard|hold|main|offer|potential|professional|international|travel|cook|alternative|following|special|working|whole|dance|excuse|cold|commercial|low|purchase|deal|primary|worth|fall|necessary|positive|produce|search|present|spend|talk|creative|tell|cost|drive|green|support|glad|remove|return|run|complex|due|effective|middle|regular|reserve|independent|leave|original|reach|rest|serve|watch|beautiful|charge|active|break|negative|safe|stay|visit|visual|affect|cover|report|rise|walk|white|beyond|junior|pick|unique|anything|classic|final|lift|mix|private|stop|teach|western|concern|familiar|fly|official|broad|comfortable|gain|maybe|rich|save|stand|young|fail|heavy|hello|lead|listen|valuable|worry|handle|leading|meet|release|sell|finish|normal|press|ride|secret|spread|spring|tough|wait|brown|deep|display|flow|hit|objective|shoot|touch|cancel|chemical|cry|dump|extreme|push|conflict|eat|fill|formal|jump|kick|opposite|pass|pitch|remote|total|treat|vast|abuse|beat|burn|deposit|print|raise|sleep|somewhere|advance|anywhere|consist|dark|double|draw|equal|fix|hire|internal|join|kill|sensitive|tap|win|attack|claim|constant|drag|drink|guess|minor|pull|raw|soft|solid|wear|weird|wonder|annual|count|dead|doubt|feed|forever|impress|nobody|repeat|round|sing|slide|strip|whereas|wish|combine|command|dig|divide|equivalent|hang|hunt|initial|march|mention|smell|spiritual|survey|tie|adult|brief|crazy|escape|gather|hate|prior|repair|rough|sad|scratch|sick|strike|employ|external|hurt|illegal|laugh|lay|mobile|nasty|ordinary|respond|royal|senior|split|strain|struggle|swim|train|upper|wash|yellow|convert|crash|dependent|fold|funny|grab|hide|miss|permit|quote|recover|resolve|roll|sink|slip|spare|suspect|sweet|swing|twist|upstairs|usual|abroad|brave|calm|concentrate|estimate|grand|male|mine|prompt|quiet|refuse|regret|reveal|rush|shake|shift|shine|steal|suck|surround|anybody|bear|brilliant|dare|dear|delay|drunk|female|hurry|inevitable|invite|kiss|neat|pop|punch|quit|reply|representative|resist|rip|rub|silly|smile|spell|stretch|stupid|tear|temporary|tomorrow|wake|wrap|yesterday'.split( - '|', - ); + "people|history|way|art|world|information|map|two|family|government|health|system|computer|meat|year|thanks|music|person|reading|method|data|food|understanding|theory|law|bird|literature|problem|software|control|knowledge|power|ability|economics|love|internet|television|science|library|nature|fact|product|idea|temperature|investment|area|society|activity|story|industry|media|thing|oven|community|definition|safety|quality|development|language|management|player|variety|video|week|security|country|exam|movie|organization|equipment|physics|analysis|policy|series|thought|basis|boyfriend|direction|strategy|technology|army|camera|freedom|paper|environment|child|instance|month|truth|marketing|university|writing|article|department|difference|goal|news|audience|fishing|growth|income|marriage|user|combination|failure|meaning|medicine|philosophy|teacher|communication|night|chemistry|disease|disk|energy|nation|road|role|soup|advertising|location|success|addition|apartment|education|math|moment|painting|politics|attention|decision|event|property|shopping|student|wood|competition|distribution|entertainment|office|population|president|unit|category|cigarette|context|introduction|opportunity|performance|driver|flight|length|magazine|newspaper|relationship|teaching|cell|dealer|finding|lake|member|message|phone|scene|appearance|association|concept|customer|death|discussion|housing|inflation|insurance|mood|woman|advice|blood|effort|expression|importance|opinion|payment|reality|responsibility|situation|skill|statement|wealth|application|city|county|depth|estate|foundation|grandmother|heart|perspective|photo|recipe|studio|topic|collection|depression|imagination|passion|percentage|resource|setting|ad|agency|college|connection|criticism|debt|description|memory|patience|secretary|solution|administration|aspect|attitude|director|personality|psychology|recommendation|response|selection|storage|version|alcohol|argument|complaint|contract|emphasis|highway|loss|membership|possession|preparation|steak|union|agreement|cancer|currency|employment|engineering|entry|interaction|mixture|preference|region|republic|tradition|virus|actor|classroom|delivery|device|difficulty|drama|election|engine|football|guidance|hotel|owner|priority|protection|suggestion|tension|variation|anxiety|atmosphere|awareness|bath|bread|candidate|climate|comparison|confusion|construction|elevator|emotion|employee|employer|guest|height|leadership|mall|manager|operation|recording|sample|transportation|charity|cousin|disaster|editor|efficiency|excitement|extent|feedback|guitar|homework|leader|mom|outcome|permission|presentation|promotion|reflection|refrigerator|resolution|revenue|session|singer|tennis|basket|bonus|cabinet|childhood|church|clothes|coffee|dinner|drawing|hair|hearing|initiative|judgment|lab|measurement|mode|mud|orange|poetry|police|possibility|procedure|queen|ratio|relation|restaurant|satisfaction|sector|signature|significance|song|tooth|town|vehicle|volume|wife|accident|airport|appointment|arrival|assumption|baseball|chapter|committee|conversation|database|enthusiasm|error|explanation|farmer|gate|girl|hall|historian|hospital|injury|instruction|maintenance|manufacturer|meal|perception|pie|poem|presence|proposal|reception|replacement|revolution|river|son|speech|tea|village|warning|winner|worker|writer|assistance|breath|buyer|chest|chocolate|conclusion|contribution|cookie|courage|dad|desk|drawer|establishment|examination|garbage|grocery|honey|impression|improvement|independence|insect|inspection|inspector|king|ladder|menu|penalty|piano|potato|profession|professor|quantity|reaction|requirement|salad|sister|supermarket|tongue|weakness|wedding|affair|ambition|analyst|apple|assignment|assistant|bathroom|bedroom|beer|birthday|celebration|championship|cheek|client|consequence|departure|diamond|dirt|ear|fortune|friendship|funeral|gene|girlfriend|hat|indication|intention|lady|midnight|negotiation|obligation|passenger|pizza|platform|poet|pollution|recognition|reputation|shirt|sir|speaker|stranger|surgery|sympathy|tale|throat|trainer|uncle|youth|time|work|film|water|money|example|while|business|study|game|life|form|air|day|place|number|part|field|fish|back|process|heat|hand|experience|job|book|end|point|type|home|economy|value|body|market|guide|interest|state|radio|course|company|price|size|card|list|mind|trade|line|care|group|risk|word|fat|force|key|light|training|name|school|top|amount|level|order|practice|research|sense|service|piece|web|boss|sport|fun|house|page|term|test|answer|sound|focus|matter|kind|soil|board|oil|picture|access|garden|range|rate|reason|future|site|demand|exercise|image|case|cause|coast|action|age|bad|boat|record|result|section|building|mouse|cash|class|nothing|period|plan|store|tax|side|subject|space|rule|stock|weather|chance|figure|man|model|source|beginning|earth|program|chicken|design|feature|head|material|purpose|question|rock|salt|act|birth|car|dog|object|scale|sun|note|profit|rent|speed|style|war|bank|craft|half|inside|outside|standard|bus|exchange|eye|fire|position|pressure|stress|advantage|benefit|box|frame|issue|step|cycle|face|item|metal|paint|review|room|screen|structure|view|account|ball|discipline|medium|share|balance|bit|black|bottom|choice|gift|impact|machine|shape|tool|wind|address|average|career|culture|morning|pot|sign|table|task|condition|contact|credit|egg|hope|ice|network|north|square|attempt|date|effect|link|post|star|voice|capital|challenge|friend|self|shot|brush|couple|debate|exit|front|function|lack|living|plant|plastic|spot|summer|taste|theme|track|wing|brain|button|click|desire|foot|gas|influence|notice|rain|wall|base|damage|distance|feeling|pair|savings|staff|sugar|target|text|animal|author|budget|discount|file|ground|lesson|minute|officer|phase|reference|register|sky|stage|stick|title|trouble|bowl|bridge|campaign|character|club|edge|evidence|fan|letter|lock|maximum|novel|option|pack|park|plenty|quarter|skin|sort|weight|baby|background|carry|dish|factor|fruit|glass|joint|master|muscle|red|strength|traffic|trip|vegetable|appeal|chart|gear|ideal|kitchen|land|log|mother|net|party|principle|relative|sale|season|signal|spirit|street|tree|wave|belt|bench|commission|copy|drop|minimum|path|progress|project|sea|south|status|stuff|ticket|tour|angle|blue|breakfast|confidence|daughter|degree|doctor|dot|dream|duty|essay|father|fee|finance|hour|juice|limit|luck|milk|mouth|peace|pipe|seat|stable|storm|substance|team|trick|afternoon|bat|beach|blank|catch|chain|consideration|cream|crew|detail|gold|interview|kid|mark|match|mission|pain|pleasure|score|screw|sex|shop|shower|suit|tone|window|agent|band|block|bone|calendar|cap|coat|contest|corner|court|cup|district|door|east|finger|garage|guarantee|hole|hook|implement|layer|lecture|lie|manner|meeting|nose|parking|partner|profile|respect|rice|routine|schedule|swimming|telephone|tip|winter|airline|bag|battle|bed|bill|bother|cake|code|curve|designer|dimension|dress|ease|emergency|evening|extension|farm|fight|gap|grade|holiday|horror|horse|host|husband|loan|mistake|mountain|nail|noise|occasion|package|patient|pause|phrase|proof|race|relief|sand|sentence|shoulder|smoke|stomach|string|tourist|towel|vacation|west|wheel|wine|arm|aside|associate|bet|blow|border|branch|breast|brother|buddy|bunch|chip|coach|cross|document|draft|dust|expert|floor|god|golf|habit|iron|judge|knife|landscape|league|mail|mess|native|opening|parent|pattern|pin|pool|pound|request|salary|shame|shelter|shoe|silver|tackle|tank|trust|assist|bake|bar|bell|bike|blame|boy|brick|chair|closet|clue|collar|comment|conference|devil|diet|fear|fuel|glove|jacket|lunch|monitor|mortgage|nurse|pace|panic|peak|plane|reward|row|sandwich|shock|spite|spray|surprise|till|transition|weekend|welcome|yard|alarm|bend|bicycle|bite|blind|bottle|cable|candle|clerk|cloud|concert|counter|flower|grandfather|harm|knee|lawyer|leather|load|mirror|neck|pension|plate|purple|ruin|ship|skirt|slice|snow|specialist|stroke|switch|trash|tune|zone|anger|award|bid|bitter|boot|bug|camp|candy|carpet|cat|champion|channel|clock|comfort|cow|crack|engineer|entrance|fault|grass|guy|hell|highlight|incident|island|joke|jury|leg|lip|mate|motor|nerve|passage|pen|pride|priest|prize|promise|resident|resort|ring|roof|rope|sail|scheme|script|sock|station|toe|tower|truck|witness|a|you|it|can|will|if|one|many|most|other|use|make|good|look|help|go|great|being|few|might|still|public|read|keep|start|give|human|local|general|she|specific|long|play|feel|high|tonight|put|common|set|change|simple|past|big|possible|particular|today|major|personal|current|national|cut|natural|physical|show|try|check|second|call|move|pay|let|increase|single|individual|turn|ask|buy|guard|hold|main|offer|potential|professional|international|travel|cook|alternative|following|special|working|whole|dance|excuse|cold|commercial|low|purchase|deal|primary|worth|fall|necessary|positive|produce|search|present|spend|talk|creative|tell|cost|drive|green|support|glad|remove|return|run|complex|due|effective|middle|regular|reserve|independent|leave|original|reach|rest|serve|watch|beautiful|charge|active|break|negative|safe|stay|visit|visual|affect|cover|report|rise|walk|white|beyond|junior|pick|unique|anything|classic|final|lift|mix|private|stop|teach|western|concern|familiar|fly|official|broad|comfortable|gain|maybe|rich|save|stand|young|fail|heavy|hello|lead|listen|valuable|worry|handle|leading|meet|release|sell|finish|normal|press|ride|secret|spread|spring|tough|wait|brown|deep|display|flow|hit|objective|shoot|touch|cancel|chemical|cry|dump|extreme|push|conflict|eat|fill|formal|jump|kick|opposite|pass|pitch|remote|total|treat|vast|abuse|beat|burn|deposit|print|raise|sleep|somewhere|advance|anywhere|consist|dark|double|draw|equal|fix|hire|internal|join|kill|sensitive|tap|win|attack|claim|constant|drag|drink|guess|minor|pull|raw|soft|solid|wear|weird|wonder|annual|count|dead|doubt|feed|forever|impress|nobody|repeat|round|sing|slide|strip|whereas|wish|combine|command|dig|divide|equivalent|hang|hunt|initial|march|mention|smell|spiritual|survey|tie|adult|brief|crazy|escape|gather|hate|prior|repair|rough|sad|scratch|sick|strike|employ|external|hurt|illegal|laugh|lay|mobile|nasty|ordinary|respond|royal|senior|split|strain|struggle|swim|train|upper|wash|yellow|convert|crash|dependent|fold|funny|grab|hide|miss|permit|quote|recover|resolve|roll|sink|slip|spare|suspect|sweet|swing|twist|upstairs|usual|abroad|brave|calm|concentrate|estimate|grand|male|mine|prompt|quiet|refuse|regret|reveal|rush|shake|shift|shine|steal|suck|surround|anybody|bear|brilliant|dare|dear|delay|drunk|female|hurry|inevitable|invite|kiss|neat|pop|punch|quit|reply|representative|resist|rip|rub|silly|smile|spell|stretch|stupid|tear|temporary|tomorrow|wake|wrap|yesterday".split( + "|", + ); export default function slug() { - return [ - adjectives[Math.trunc(Math.random() * adjectives.length)], - adjectives[Math.trunc(Math.random() * adjectives.length)], - nouns[Math.trunc(Math.random() * nouns.length)], - ].join('-'); + return [ + adjectives[Math.trunc(Math.random() * adjectives.length)], + adjectives[Math.trunc(Math.random() * adjectives.length)], + nouns[Math.trunc(Math.random() * nouns.length)], + ].join("-"); } diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index 4360c78..dcf7d21 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -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 { - return new Promise((resolve) => { - setTimeout(() => resolve(), ms); - }); + return new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); } export default { getInterval }; diff --git a/frontend/src/lib/twitch.ts b/frontend/src/lib/twitch.ts index 49b571b..aeb5056 100644 --- a/frontend/src/lib/twitch.ts +++ b/frontend/src/lib/twitch.ts @@ -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 { - 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; + return req.json() as Promise; } /** @@ -44,18 +44,18 @@ export async function twitchAuth( * @throws Credentials are not valid or request failed */ export async function checkTwitchKeys(clientId: string, clientSecret: string): Promise { - 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, - }, - }); + 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}`); + } } /** @@ -63,6 +63,6 @@ export async function checkTwitchKeys(clientId: string, clientSecret: string): P * @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); } diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 65be9ea..232adf6 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -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", - "apiguide-3": "Use the following data for the required fields:", - "apiguide-4": "Once made, create a <1>New Secret, 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 GNU Affero General Public License v3.0", - "credits-header": "Credits", - "credits-renko": "Renko, {{APPNAME}}'s mascot and app icon, was drawn by Sonic_Chan" - }, - "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": "{{name}} followed you", - "redemption": "{{name}} redeemed {{reward}}", - "stream-start": "You started streaming", - "stream-stop": "You stopped streaming", - "channel-updated": "Stream info changed", - "raided": "{{name}} raided you with {{viewers}} viewers", - "cheered": "{{name}} cheered you with {{bits}} bits", - "subscribed": "{{name}} subscribed to you (Tier {{tier}})", - "subscribed-multi": "{{name}} subscribed to you ({{months}} months) (Tier {{tier}})", - "subscrition-gift_one": "{{name}} gifted {{count}} subscription (Tier {{tier}})", - "subscrition-gift_other": "{{name}} gifted {{count}} subscriptions (Tier {{tier}})" - }, - "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.
Click here 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 clicking here." - }, - "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 {{A}} and {{B}}.", - "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 {{A}} and {{B}}", - "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: {{code}} 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", + "apiguide-3": "Use the following data for the required fields:", + "apiguide-4": "Once made, create a <1>New Secret, 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 GNU Affero General Public License v3.0", + "credits-header": "Credits", + "credits-renko": "Renko, {{APPNAME}}'s mascot and app icon, was drawn by Sonic_Chan" + }, + "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": "{{name}} followed you", + "redemption": "{{name}} redeemed {{reward}}", + "stream-start": "You started streaming", + "stream-stop": "You stopped streaming", + "channel-updated": "Stream info changed", + "raided": "{{name}} raided you with {{viewers}} viewers", + "cheered": "{{name}} cheered you with {{bits}} bits", + "subscribed": "{{name}} subscribed to you (Tier {{tier}})", + "subscribed-multi": "{{name}} subscribed to you ({{months}} months) (Tier {{tier}})", + "subscrition-gift_one": "{{name}} gifted {{count}} subscription (Tier {{tier}})", + "subscrition-gift_other": "{{name}} gifted {{count}} subscriptions (Tier {{tier}})" + }, + "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.
Click here 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 clicking here." + }, + "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 {{A}} and {{B}}.", + "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 {{A}} and {{B}}", + "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: {{code}} 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" + } } diff --git a/frontend/src/locale/it/translation.json b/frontend/src/locale/it/translation.json index db38a4d..60e9c98 100644 --- a/frontend/src/locale/it/translation.json +++ b/frontend/src/locale/it/translation.json @@ -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": "{{name}} ti ha tifato con {{bits}} bit", - "follow": "{{name}} ti ha seguito", - "raided": "{{name}} ti ha fatto un raid con {{viewers}} spettatori", - "redemption": "{{name}} ha riscattato {{reward}}", - "stream-start": "Hai iniziato un nuovo stream", - "stream-stop": "Hai chiuso lo stream", - "subscribed": "{{name}} si è abbonato (Livello {{tier}})", - "subscribed-multi": "{{name}} si è abbonato ({{months}} mesi) (Livello {{tier}})", - "subscrition-gift_one": "{{name}} ha regalato {{count}} abbonamento (Livello {{tier}})", - "subscrition-gift_other": "{{name}} ha regalato {{count}} abbonamenti (Livello {{tier}})" - }, - "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.
Fai clic qui 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 cliccando qui." - }, - "strimertul": { - "credits-header": "Ringraziamenti", - "credits-renko": "Renko, mascotte e icona di {{APPNAME}}, è stata disegnata da Sonic_Chan", - "license-header": "Licenza", - "license-notice-strimertul": "{{APPNAME}} è concesso sotto licenza GNU Affero General Public License v3.0", - "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", - "apiguide-3": "Utilizza i seguenti dati per i campi obbligatori:", - "apiguide-4": "Una volta creato, crea un <1>Nuovo segreto, 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 {{A}} e {{B}}.", - "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 {{A}} e {{B}}", - "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: {{code}} 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": "{{name}} ti ha tifato con {{bits}} bit", + "follow": "{{name}} ti ha seguito", + "raided": "{{name}} ti ha fatto un raid con {{viewers}} spettatori", + "redemption": "{{name}} ha riscattato {{reward}}", + "stream-start": "Hai iniziato un nuovo stream", + "stream-stop": "Hai chiuso lo stream", + "subscribed": "{{name}} si è abbonato (Livello {{tier}})", + "subscribed-multi": "{{name}} si è abbonato ({{months}} mesi) (Livello {{tier}})", + "subscrition-gift_one": "{{name}} ha regalato {{count}} abbonamento (Livello {{tier}})", + "subscrition-gift_other": "{{name}} ha regalato {{count}} abbonamenti (Livello {{tier}})" + }, + "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.
Fai clic qui 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 cliccando qui." + }, + "strimertul": { + "credits-header": "Ringraziamenti", + "credits-renko": "Renko, mascotte e icona di {{APPNAME}}, è stata disegnata da Sonic_Chan", + "license-header": "Licenza", + "license-notice-strimertul": "{{APPNAME}} è concesso sotto licenza GNU Affero General Public License v3.0", + "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", + "apiguide-3": "Utilizza i seguenti dati per i campi obbligatori:", + "apiguide-4": "Una volta creato, crea un <1>Nuovo segreto, 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 {{A}} e {{B}}.", + "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 {{A}} e {{B}}", + "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: {{code}} 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" + } } diff --git a/frontend/src/locale/languages.ts b/frontend/src/locale/languages.ts index 7f5d41f..7faab00 100644 --- a/frontend/src/locale/languages.ts +++ b/frontend/src/locale/languages.ts @@ -1,31 +1,31 @@ -import type { 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((acc: number, k: ResourceKey) => acc + countKeys(k), 0); + if (typeof res === "string") { + return 1; + } + return Object.values(res).reduce((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; diff --git a/frontend/src/locale/setup.ts b/frontend/src/locale/setup.ts index 6ad65e9..76a3c1d 100644 --- a/frontend/src/locale/setup.ts +++ b/frontend/src/locale/setup.ts @@ -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, + }, + }, }); diff --git a/frontend/src/store/api/reducer.ts b/frontend/src/store/api/reducer.ts index d827f87..7a63511 100644 --- a/frontend/src/store/api/reducer.ts +++ b/frontend/src/store/api/reducer.ts @@ -1,82 +1,82 @@ import { - 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'; + 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 { - 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 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(key: string) { - const getter = createAsyncThunk( - `api/get/${key}`, - async (_, { getState }) => { - const { api } = getState(); - return api.client.getJSON(key); - }, - ); - const setter = createAsyncThunk( - `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( + `api/get/${key}`, + async (_, { getState }) => { + const { api } = getState(); + return api.client.getJSON(key); + }, + ); + const setter = createAsyncThunk( + `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( - key: string, - selector: (state: APIState) => T, - stateSetter: CaseReducer, + key: string, + selector: (state: APIState) => T, + stateSetter: CaseReducer, ) { - return { - ...makeGetSetThunks(key), - key, - selector, - stateSetter, - asyncSetter: createAction(`asyncSetter/${key}`), - }; + return { + ...makeGetSetThunks(key), + key, + selector, + stateSetter, + asyncSetter: createAction(`asyncSetter/${key}`), + }; } // biome-ignore lint/style/useConst: Assigned later @@ -85,315 +85,315 @@ let setupClientReconnect: AsyncThunk; let kvErrorReceived: AsyncThunk; // 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) => { - 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) => { + void dispatch(kvErrorReceived(err.detail)); + }); + await client.connect(); + await dispatch(setupClientReconnect(client)); + return client; + }, ); export const getUserPoints = createAsyncThunk( - '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; - }, + "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( - 'api/createRedeem', - async (redeem: LoyaltyRedeem, { getState }) => { - const { api } = getState(); - return api.client.putJSON(loyaltyCreateRedeemKey, redeem); - }, + "api/createRedeem", + async (redeem: LoyaltyRedeem, { getState }) => { + const { api } = getState(); + return api.client.putJSON(loyaltyCreateRedeemKey, redeem); + }, ); export const removeRedeem = createAsyncThunk( - 'api/removeRedeem', - async (redeem: LoyaltyRedeem, { getState }) => { - const { api } = getState(); - return api.client.putJSON(loyaltyRemoveRedeemKey, redeem); - }, + "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) => never + `${keyof typeof modules}Changed`, + (state: APIState, action: PayloadAction) => 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) { - state.connectionStatus = payload; - }, - kvErrorReceived(state, { payload }: PayloadAction) { - state.kvError = payload; - }, - loyaltyUserPointsChanged( - state, - { payload: { user, entry } }: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>, - ) { - state.loyalty.users[user] = entry; - }, - requestKeysRemoved(state, { payload }: PayloadAction) { - 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); - } - }, + name: "api", + initialState, + reducers: { + ...moduleChangeReducers, + initialLoadCompleted(state) { + state.initialLoadComplete = true; + }, + connectionStatusChanged(state, { payload }: PayloadAction) { + state.connectionStatus = payload; + }, + kvErrorReceived(state, { payload }: PayloadAction) { + state.kvError = payload; + }, + loyaltyUserPointsChanged( + state, + { payload: { user, entry } }: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>, + ) { + state.loyalty.users[user] = entry; + }, + requestKeysRemoved(state, { payload }: PayloadAction) { + 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( - '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)); - } - }, + "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; diff --git a/frontend/src/store/api/types.ts b/frontend/src/store/api/types.ts index be272a7..ffd61e8 100644 --- a/frontend/src/store/api/types.ts +++ b/frontend/src/store/api/types.ts @@ -1,181 +1,181 @@ /* eslint-disable camelcase */ -import type 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; 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; + timers: Record; } 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; 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; + enabled: boolean; + id: string; + name: string; + description: string; + image: string; + total: number; + contributed: number; + contributors: Record; } 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 = 'not-connected', - AuthenticationNeeded = 'auth-needed', - Connected = '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; + 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; } diff --git a/frontend/src/store/extensions/reducer.ts b/frontend/src/store/extensions/reducer.ts index c9da8ef..2e21969 100644 --- a/frontend/src/store/extensions/reducer.ts +++ b/frontend/src/store/extensions/reducer.ts @@ -1,317 +1,317 @@ -import { createAsyncThunk, createSlice, type 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 { - type ExtensionDependencies, - type ExtensionOptions, - type ExtensionRunOptions, - ExtensionStatus, -} from '~/lib/extensions/types'; -import type { ThunkConfig } from '..'; -import type { 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; - running: Record; - unsaved: Record; - status: Record; - editorCurrentFile: string; - dependencies: ExtensionDependencies; + ready: boolean; + installed: Record; + running: Record; + unsaved: Record; + status: Record; + 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) { - state.dependencies = payload; - state.ready = true; - }, - editorSelectedFile(state, { payload }: PayloadAction) { - state.editorCurrentFile = payload; - }, - extensionDrafted(state, { payload }: PayloadAction) { - 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) { - state.unsaved[state.editorCurrentFile] = payload; - }, - extensionStatusChanged( - state, - { payload }: PayloadAction<{ name: string; status: ExtensionStatus }>, - ) { - state.status[payload.name] = payload.status; - }, - extensionAdded(state, { payload }: PayloadAction) { - // 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; - } - - state.installed[payload.name] = payload; - }, - extensionInstanceAdded(state, { payload }: PayloadAction) { - // 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) { - // 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]; - - // 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; - } - }, - }, + name: "extensions", + initialState, + reducers: { + initialized(state, { payload }: PayloadAction) { + state.dependencies = payload; + state.ready = true; + }, + editorSelectedFile(state, { payload }: PayloadAction) { + state.editorCurrentFile = payload; + }, + extensionDrafted(state, { payload }: PayloadAction) { + 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) { + state.unsaved[state.editorCurrentFile] = payload; + }, + extensionStatusChanged( + state, + { payload }: PayloadAction<{ name: string; status: ExtensionStatus }>, + ) { + state.status[payload.name] = payload.status; + }, + extensionAdded(state, { payload }: PayloadAction) { + // 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; + } + + state.installed[payload.name] = payload; + }, + extensionInstanceAdded(state, { payload }: PayloadAction) { + // 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) { + // 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]; + + // 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) => { - 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) => { + 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( - '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)); - } - }, + "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)); + } + }, ); export const initializeExtensions = createAsyncThunk( - 'extensions/initialize', - async (_, { getState, dispatch }) => { - // Get kv client - const { api } = getState(); - - // Get kilovolt endpoint/credentials - const httpConfig = await api.client.getJSON('http/config'); - - // 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, - }), - ); - }); - - // 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)); - } - }), - ); - }, + "extensions/initialize", + async (_, { getState, dispatch }) => { + // Get kv client + const { api } = getState(); + + // Get kilovolt endpoint/credentials + const httpConfig = await api.client.getJSON("http/config"); + + // 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, + }), + ); + }); + + // 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( - '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; - } - - extensions.running[name].start(); - }, + "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; + } + + extensions.running[name].start(); + }, ); export const stopExtension = createAsyncThunk( - '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( - 'extensions/save', - async (entry, { getState }) => { - // Get kv client - const { api } = getState(); - await api.client.putJSON(extensionPrefix + entry.name, entry); - }, + "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( - '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( - '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( - 'extensions/rename', - async (payload, { getState, dispatch }) => { - const { extensions } = getState(); - - // 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, - }), - ); - - // Set unsaved and current file - dispatch(extensionsReducer.actions.editorSelectedFile(payload.to)); - if (unsaved) { - dispatch(extensionsReducer.actions.extensionSourceChanged(unsaved)); - } - }, + "extensions/rename", + async (payload, { getState, dispatch }) => { + const { extensions } = getState(); + + // 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, + }), + ); + + // Set unsaved and current file + dispatch(extensionsReducer.actions.editorSelectedFile(payload.to)); + if (unsaved) { + dispatch(extensionsReducer.actions.extensionSourceChanged(unsaved)); + } + }, ); export default extensionsReducer; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 599f8fe..f18bbf2 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,24 +1,24 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { type 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; @@ -26,8 +26,8 @@ export type AppDispatch = typeof store.dispatch; export type ThunkConfig = { state: RootState }; export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: ( - selector: (state: RootState) => Selected, - equalityFn?: EqualityFn | undefined, + selector: (state: RootState) => Selected, + equalityFn?: EqualityFn | undefined, ) => Selected = useSelector; export default store; diff --git a/frontend/src/store/logging/reducer.ts b/frontend/src/store/logging/reducer.ts index 3d12b21..c3875ac 100644 --- a/frontend/src/store/logging/reducer.ts +++ b/frontend/src/store/logging/reducer.ts @@ -1,55 +1,55 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { 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, - }; + 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) { - const logKeys = payload.map(keyfn); - - // 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) { - state.messages.push(processEntry(payload)); - }, - clearedEvents(state) { - state.messages = []; - }, - }, + name: "logging", + initialState, + reducers: { + loadedLogData(state, { payload }: PayloadAction) { + const logKeys = payload.map(keyfn); + + // 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) { + state.messages.push(processEntry(payload)); + }, + clearedEvents(state) { + state.messages = []; + }, + }, }); export default loggingReducer; diff --git a/frontend/src/store/server/reducer.ts b/frontend/src/store/server/reducer.ts index 6fef7d2..3c800a7 100644 --- a/frontend/src/store/server/reducer.ts +++ b/frontend/src/store/server/reducer.ts @@ -1,31 +1,31 @@ /* eslint-disable no-param-reassign */ -import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import { GetAppVersion } from '@wailsapp/go/main/App'; -import type { 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) { - state.version = payload; - }, - }, + name: "server", + initialState, + reducers: { + loadedVersionData(state, { payload }: PayloadAction) { + state.version = payload; + }, + }, }); export const initializeServerInfo = createAsyncThunk( - 'server/init-info', - async (_: never, { dispatch }) => { - dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion())); - }, + "server/init-info", + async (_: never, { dispatch }) => { + dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion())); + }, ); export default serverReducer; diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 57b803c..6ba7be6 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -1,282 +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 type { 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, { 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'; +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: , - }, - ], - }, - { - title: 'menu.sections.strimertul', - short: 'menu.sections.strimertul-short', - links: [ - { - title: 'menu.pages.strimertul.settings', - url: '/http', - icon: , - }, - { - title: 'menu.pages.strimertul.ui-config', - url: '/ui-config', - icon: , - }, - { - title: 'menu.pages.strimertul.extensions', - url: '/extensions', - icon: , - }, - ], - }, - { - title: 'menu.sections.twitch', - short: 'menu.sections.twitch-short', - links: [ - { - title: 'menu.pages.twitch.configuration', - url: '/twitch/settings', - icon: , - }, - { - title: 'menu.pages.twitch.chat-commands', - url: '/twitch/chat/commands', - icon: , - }, - { - title: 'menu.pages.twitch.chat-timers', - url: '/twitch/chat/timers', - icon: , - }, - { - title: 'menu.pages.twitch.chat-alerts', - url: '/twitch/chat/alerts', - icon: , - }, - ], - }, - { - title: 'menu.sections.loyalty', - short: 'menu.sections.loyalty-short', - links: [ - { - title: 'menu.pages.loyalty.configuration', - url: '/loyalty/settings', - icon: , - }, - { - title: 'menu.pages.loyalty.points', - url: '/loyalty/users', - icon: , - }, - { - title: 'menu.pages.loyalty.rewards', - url: '/loyalty/rewards', - icon: , - }, - ], - }, + { + title: "menu.sections.monitor", + short: "menu.sections.monitor-short", + links: [ + { + title: "menu.pages.monitor.dashboard", + url: "/", + icon: , + }, + ], + }, + { + title: "menu.sections.strimertul", + short: "menu.sections.strimertul-short", + links: [ + { + title: "menu.pages.strimertul.settings", + url: "/http", + icon: , + }, + { + title: "menu.pages.strimertul.ui-config", + url: "/ui-config", + icon: , + }, + { + title: "menu.pages.strimertul.extensions", + url: "/extensions", + icon: , + }, + ], + }, + { + title: "menu.sections.twitch", + short: "menu.sections.twitch-short", + links: [ + { + title: "menu.pages.twitch.configuration", + url: "/twitch/settings", + icon: , + }, + { + title: "menu.pages.twitch.chat-commands", + url: "/twitch/chat/commands", + icon: , + }, + { + title: "menu.pages.twitch.chat-timers", + url: "/twitch/chat/timers", + icon: , + }, + { + title: "menu.pages.twitch.chat-alerts", + url: "/twitch/chat/alerts", + icon: , + }, + ], + }, + { + title: "menu.sections.loyalty", + short: "menu.sections.loyalty-short", + links: [ + { + title: "menu.pages.loyalty.configuration", + url: "/loyalty/settings", + icon: , + }, + { + title: "menu.pages.loyalty.points", + url: "/loyalty/users", + icon: , + }, + { + title: "menu.pages.loyalty.rewards", + url: "/loyalty/rewards", + icon: , + }, + ], + }, ]; -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 = 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 [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(); - // 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 + // 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); + } + }, []); - // 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(() => { + const connectToKV = async () => { + const address = await GetKilovoltBind(); + await dispatch( + createWSClient({ + address: `ws://${address}/ws`, + }), + ); + }; - 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 - // 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]); + // 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 ; - } + if ( + connected === ConnectionStatus.NotConnected || + connected === ConnectionStatus.AuthenticationNeeded + ) { + return ; + } - const showSidebar = location.pathname !== '/setup'; + const showSidebar = location.pathname !== "/setup"; - return ( - - - - {showSidebar ? : null} - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ); + return ( + + + + {showSidebar ? : null} + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); } diff --git a/frontend/src/ui/ErrorWindow.tsx b/frontend/src/ui/ErrorWindow.tsx index 1346182..f9b9c17 100644 --- a/frontend/src/ui/ErrorWindow.tsx +++ b/frontend/src/ui/ErrorWindow.tsx @@ -1,568 +1,568 @@ -import { CheckIcon } from '@radix-ui/react-icons'; -import { GetBackups, GetLastLogs, RestoreBackup, SendCrashReport } from '@wailsapp/go/main/App'; -import type { main } from '@wailsapp/go/models'; -import { EventsOff, EventsOn } from '@wailsapp/runtime'; -import { Fragment, useEffect, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { languages } from '~/locale/languages'; -import { type ProcessedLogEntry, processEntry } from '~/store/logging/reducer'; -import DialogContent from '~/ui/components/DialogContent'; -import { LogItem } from '~/ui/components/LogViewer'; -import Scrollbar from '~/ui/components/utils/Scrollbar'; +import { CheckIcon } from "@radix-ui/react-icons"; +import { GetBackups, GetLastLogs, RestoreBackup, SendCrashReport } from "@wailsapp/go/main/App"; +import type { main } from "@wailsapp/go/models"; +import { EventsOff, EventsOn } from "@wailsapp/runtime"; +import { Fragment, useEffect, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { languages } from "~/locale/languages"; +import { type ProcessedLogEntry, processEntry } from "~/store/logging/reducer"; +import DialogContent from "~/ui/components/DialogContent"; +import { LogItem } from "~/ui/components/LogViewer"; +import Scrollbar from "~/ui/components/utils/Scrollbar"; import { - Button, - Checkbox, - CheckboxIndicator, - Dialog, - DialogActions, - Field, - FlexRow, - getTheme, - InputBox, - Label, - MultiToggle, - MultiToggleItem, - PageContainer, - PageHeader, - SectionHeader, - styled, - Textarea, - TextBlock, -} from '~/ui/theme'; -import AlertContent from './components/AlertContent'; -import { Alert, AlertDescription, AlertTrigger } from './theme/alert'; + Button, + Checkbox, + CheckboxIndicator, + Dialog, + DialogActions, + Field, + FlexRow, + getTheme, + InputBox, + Label, + MultiToggle, + MultiToggleItem, + PageContainer, + PageHeader, + SectionHeader, + styled, + Textarea, + TextBlock, +} from "~/ui/theme"; +import AlertContent from "./components/AlertContent"; +import { Alert, AlertDescription, AlertTrigger } from "./theme/alert"; -const Container = styled('div', { - position: 'relative', - display: 'flex', - flexDirection: 'row', - overflow: 'hidden', - height: '100vh', - border: '2px solid $red10', - backgroundColor: '$gray1', - color: '$gray12', +const Container = styled("div", { + position: "relative", + display: "flex", + flexDirection: "row", + overflow: "hidden", + height: "100vh", + border: "2px solid $red10", + backgroundColor: "$gray1", + color: "$gray12", }); -const ErrorHeader = styled('h1', { - color: '$red10', - textTransform: 'capitalize', +const ErrorHeader = styled("h1", { + color: "$red10", + textTransform: "capitalize", }); -const ErrorDetails = styled('dl', { - display: 'grid', - gridTemplateColumns: '100px 1fr', - margin: '0', +const ErrorDetails = styled("dl", { + display: "grid", + gridTemplateColumns: "100px 1fr", + margin: "0", }); -const ErrorDetailKey = styled('dt', { - fontWeight: 'bold', - textTransform: 'capitalize', - gridColumn: '1', +const ErrorDetailKey = styled("dt", { + fontWeight: "bold", + textTransform: "capitalize", + gridColumn: "1", }); -const ErrorDetailValue = styled('dd', { - padding: '0', - margin: '0', - marginBottom: '0.5rem', - gridColumn: '2', +const ErrorDetailValue = styled("dd", { + padding: "0", + margin: "0", + marginBottom: "0.5rem", + gridColumn: "2", }); -const LogContainer = styled('div', { - display: 'flex', - flexDirection: 'column', - gap: '3px', +const LogContainer = styled("div", { + display: "flex", + flexDirection: "column", + gap: "3px", }); -const Mono = styled('code', { - background: '$gray5', - padding: '3px 5px', - borderRadius: '3px', - whiteSpace: 'nowrap', +const Mono = styled("code", { + background: "$gray5", + padding: "3px 5px", + borderRadius: "3px", + whiteSpace: "nowrap", }); const MiniHeader = styled(SectionHeader, { - fontSize: '14pt', + fontSize: "14pt", }); -const LanguageSelector = styled('div', { - top: '10px', - right: '10px', - display: 'flex', - gap: '1rem', - position: 'absolute', - zIndex: '1', +const LanguageSelector = styled("div", { + top: "10px", + right: "10px", + display: "flex", + gap: "1rem", + position: "absolute", + zIndex: "1", }); const LanguageItem = styled(MultiToggleItem, { - fontSize: '8pt', - padding: '5px 6px 4px', - textTransform: 'uppercase', + fontSize: "8pt", + padding: "5px 6px 4px", + textTransform: "uppercase", }); -const BackupItem = styled('article', { - backgroundColor: '$gray2', - padding: '0.3rem 1rem 0.3rem 0.5rem', - borderRadius: '0.25rem', - borderBottom: '1px solid $gray5', - transition: 'all 50ms', - display: 'flex', - '&:nth-child(odd)': { - backgroundColor: '$gray3', - }, - gap: '0.5rem', +const BackupItem = styled("article", { + backgroundColor: "$gray2", + padding: "0.3rem 1rem 0.3rem 0.5rem", + borderRadius: "0.25rem", + borderBottom: "1px solid $gray5", + transition: "all 50ms", + display: "flex", + "&:nth-child(odd)": { + backgroundColor: "$gray3", + }, + gap: "0.5rem", }); -const BackupDate = styled('div', { - display: 'flex', - alignItems: 'center', - flex: '1', - gap: '0.5rem', - fontVariantNumeric: 'tabular-nums', +const BackupDate = styled("div", { + display: "flex", + alignItems: "center", + flex: "1", + gap: "0.5rem", + fontVariantNumeric: "tabular-nums", }); -const BackupSize = styled('div', { - color: '$gray10', - alignItems: 'center', - display: 'flex', +const BackupSize = styled("div", { + color: "$gray10", + alignItems: "center", + display: "flex", }); -const BackupActions = styled('div', { - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - gap: '0.25rem', +const BackupActions = styled("div", { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "0.25rem", }); interface RecoveryDialogProps { - open: boolean; - onOpenChange: (state: boolean) => void; + open: boolean; + onOpenChange: (state: boolean) => void; } // Returns a human-readable version of a byte size function hrsize(bytes: number): string { - const units = ['B', 'KiB', 'MiB', 'GiB']; - let fractBytes = bytes; - while (fractBytes >= 1024) { - fractBytes /= 1024; - units.shift(); - } - return `${fractBytes.toFixed(2)} ${units[0]}`; + const units = ["B", "KiB", "MiB", "GiB"]; + let fractBytes = bytes; + while (fractBytes >= 1024) { + fractBytes /= 1024; + units.shift(); + } + return `${fractBytes.toFixed(2)} ${units[0]}`; } function RecoveryDialog({ open, onOpenChange }: RecoveryDialogProps) { - const { t } = useTranslation(); - const [backups, setBackups] = useState([]); - const [restoreError, setRestoreError] = useState(null); - const [restored, setRestored] = useState<'idle' | 'in-progress' | 'done'>('idle'); + const { t } = useTranslation(); + const [backups, setBackups] = useState([]); + const [restoreError, setRestoreError] = useState(null); + const [restored, setRestored] = useState<"idle" | "in-progress" | "done">("idle"); - useEffect(() => { - void GetBackups().then((backupList) => { - setBackups(backupList); - }); - }, []); + useEffect(() => { + void GetBackups().then((backupList) => { + setBackups(backupList); + }); + }, []); - const restore = async (filename: string) => { - setRestored('in-progress'); - try { - await RestoreBackup(filename); - setRestoreError(null); - } catch (err) { - setRestoreError(err as string); - } - setRestored('done'); - }; + const restore = async (filename: string) => { + setRestored("in-progress"); + try { + await RestoreBackup(filename); + setRestoreError(null); + } catch (err) { + setRestoreError(err as string); + } + setRestored("done"); + }; - if (restored === 'done' && restoreError == null) { - return ( - { - if (onOpenChange) { - onOpenChange(state); - } - setRestored('idle'); - }} - > - { - if (onOpenChange) { - onOpenChange(false); - } - setRestored('idle'); - }} - /> - - ); - } + if (restored === "done" && restoreError == null) { + return ( + { + if (onOpenChange) { + onOpenChange(state); + } + setRestored("idle"); + }} + > + { + if (onOpenChange) { + onOpenChange(false); + } + setRestored("idle"); + }} + /> + + ); + } - return ( - <> - { - if (!val) { - setRestoreError(null); - } - }} - > - { - setRestoreError(null); - }} - /> - - { - if (onOpenChange) { - onOpenChange(state); - } - }} - > - - {t('pages.crash.recovery.text-head')} - {t('pages.crash.recovery.restore-head')} - {t('pages.crash.recovery.restore-desc-1')} - - {backups - .sort((a, b) => b.date - a.date) - .map((backup) => { - const date = new Date(backup.date); + return ( + <> + { + if (!val) { + setRestoreError(null); + } + }} + > + { + setRestoreError(null); + }} + /> + + { + if (onOpenChange) { + onOpenChange(state); + } + }} + > + + {t("pages.crash.recovery.text-head")} + {t("pages.crash.recovery.restore-head")} + {t("pages.crash.recovery.restore-desc-1")} + + {backups + .sort((a, b) => b.date - a.date) + .map((backup) => { + const date = new Date(backup.date); - return ( - - - {date.toLocaleDateString([], { - year: 'numeric', - month: 'short', - day: '2-digit', - })} - {' - '} - {date.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - {hrsize(backup.size)} - - - - - - - { - void restore(backup.filename); - }} - /> - - - - ); - })} - - - - - ); + return ( + + + {date.toLocaleDateString([], { + year: "numeric", + month: "short", + day: "2-digit", + })} + {" - "} + {date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + {hrsize(backup.size)} + + + + + + + { + void restore(backup.filename); + }} + /> + + + + ); + })} + + + + + ); } interface ReportDialogProps { - open: boolean; - onOpenChange: (state: boolean) => void; - errorData?: ProcessedLogEntry; + open: boolean; + onOpenChange: (state: boolean) => void; + errorData?: ProcessedLogEntry; } function ReportDialog({ open, onOpenChange, errorData }: ReportDialogProps) { - const { t } = useTranslation(); - const [errorDesc, setErrorDesc] = useState(''); - const [contactEnabled, setContactEnabled] = useState(false); - const [contactInfo, setContactInfo] = useState(''); - const [submitted, setSubmitted] = useState(false); - const [code, setCode] = useState(''); - const [submissionError, setSubmissionError] = useState(null); + const { t } = useTranslation(); + const [errorDesc, setErrorDesc] = useState(""); + const [contactEnabled, setContactEnabled] = useState(false); + const [contactInfo, setContactInfo] = useState(""); + const [submitted, setSubmitted] = useState(false); + const [code, setCode] = useState(""); + const [submissionError, setSubmissionError] = useState(null); - const waiting = submitted && code.length < 1; + const waiting = submitted && code.length < 1; - if (code) { - return ( - { - if (onOpenChange) { - onOpenChange(state); - } - }} - > - { - setSubmissionError(null); - }} - > - - - ), - }} - /> - - - - ); - } + if (code) { + return ( + { + if (onOpenChange) { + onOpenChange(state); + } + }} + > + { + setSubmissionError(null); + }} + > + + + ), + }} + /> + + + + ); + } - return ( - <> - { - if (!val) { - setSubmissionError(null); - } - }} - > - { - setSubmissionError(null); - }} - /> - - { - if (onOpenChange) { - onOpenChange(state); - } - }} - > - -
{ - e.preventDefault(); - let desc = errorDesc; - if (contactEnabled && contactInfo) { - desc += `\n\nEmail contact: ${contactInfo}`; - } - SendCrashReport(JSON.stringify(errorData), desc) - .then((submissionCode) => { - setCode(submissionCode); - }) - .catch((err) => { - setSubmissionError(err as string); - }); - setSubmitted(true); - }} - > - {t('pages.crash.report.thanks-line')} - - {t('pages.crash.report.transparency-line')} -
    -
  • - , - }} - /> -
  • -
  • {t('pages.crash.report.transparency-info')}
  • -
  • {t('pages.crash.report.transparency-user')}
  • -
-
- - - - - - - setContactInfo(e.target.value)} - /> - - - - -
-
-
- - ); + return ( + <> + { + if (!val) { + setSubmissionError(null); + } + }} + > + { + setSubmissionError(null); + }} + /> + + { + if (onOpenChange) { + onOpenChange(state); + } + }} + > + +
{ + e.preventDefault(); + let desc = errorDesc; + if (contactEnabled && contactInfo) { + desc += `\n\nEmail contact: ${contactInfo}`; + } + SendCrashReport(JSON.stringify(errorData), desc) + .then((submissionCode) => { + setCode(submissionCode); + }) + .catch((err) => { + setSubmissionError(err as string); + }); + setSubmitted(true); + }} + > + {t("pages.crash.report.thanks-line")} + + {t("pages.crash.report.transparency-line")} +
    +
  • + , + }} + /> +
  • +
  • {t("pages.crash.report.transparency-info")}
  • +
  • {t("pages.crash.report.transparency-user")}
  • +
+
+ + + + + + + setContactInfo(e.target.value)} + /> + + + + +
+
+
+ + ); } export default function ErrorWindow(): JSX.Element { - const [t, i18n] = useTranslation(); - const [logs, setLogs] = useState([]); - const [reportDialogOpen, setReportDialogOpen] = useState(false); - const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false); + const [t, i18n] = useTranslation(); + const [logs, setLogs] = useState([]); + const [reportDialogOpen, setReportDialogOpen] = useState(false); + const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: One time setup - useEffect(() => { - void i18n.changeLanguage(localStorage.getItem('language') ?? 'en'); - void GetLastLogs().then((appLogs) => { - setLogs(appLogs.map(processEntry).reverse()); - }); - EventsOn('log-event', (event: main.LogEntry) => { - setLogs([processEntry(event), ...logs]); - }); - return () => { - EventsOff('log-event'); - }; - }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: One time setup + useEffect(() => { + void i18n.changeLanguage(localStorage.getItem("language") ?? "en"); + void GetLastLogs().then((appLogs) => { + setLogs(appLogs.map(processEntry).reverse()); + }); + EventsOn("log-event", (event: main.LogEntry) => { + setLogs([processEntry(event), ...logs]); + }); + return () => { + EventsOff("log-event"); + }; + }, []); - const fatal = logs.find((log) => log.level === 'error'); - const theme = getTheme(localStorage.getItem('theme') ?? 'dark'); + const fatal = logs.find((log) => log.level === "error"); + const theme = getTheme(localStorage.getItem("theme") ?? "dark"); - return ( - - - - - { - void i18n.changeLanguage(newLang); - }} - > - {languages.map((lang) => ( - - {lang.code} - - ))} - - - -
- - - {t('pages.crash.fatal-message')} - - {fatal ? ( - <> - {fatal.message} - - {Object.keys(fatal.data) - .filter((key) => key.length > 1) - .map((key) => ( - - {key} - {fatal.data[key]} - - ))} - - - ) : null} - {t('pages.crash.action-header')} - {t('pages.crash.action-submit-line')} - {t('pages.crash.action-recover-line')} - - , - }} - /> - - - - - + return ( + + + + + { + void i18n.changeLanguage(newLang); + }} + > + {languages.map((lang) => ( + + {lang.code} + + ))} + + + +
+ + + {t("pages.crash.fatal-message")} + + {fatal ? ( + <> + {fatal.message} + + {Object.keys(fatal.data) + .filter((key) => key.length > 1) + .map((key) => ( + + {key} + {fatal.data[key]} + + ))} + + + ) : null} + {t("pages.crash.action-header")} + {t("pages.crash.action-submit-line")} + {t("pages.crash.action-recover-line")} + + , + }} + /> + + + + + - {t('pages.crash.app-log-header')} - - {logs.map((log) => ( - - ))} - - -
-
-
- ); + {t("pages.crash.app-log-header")} + + {logs.map((log) => ( + + ))} + +
+
+
+
+ ); } diff --git a/frontend/src/ui/components/AlertContent.tsx b/frontend/src/ui/components/AlertContent.tsx index a7c388f..1d0f505 100644 --- a/frontend/src/ui/components/AlertContent.tsx +++ b/frontend/src/ui/components/AlertContent.tsx @@ -1,70 +1,70 @@ -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 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; - variation?: 'default' | 'danger'; - onAction?: () => void; + title?: string; + description?: string; + actionText?: string; + showCancel?: boolean; + cancelText?: string; + actionButtonProps?: VariantProps; + 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) { - const { t } = useTranslation(); + const { t } = useTranslation(); - return ( - - - - {title && {title}} - {description && ( - {description} - )} - {children} - - - - - {showCancel && ( - - - - )} - - - - ); + return ( + + + + {title && {title}} + {description && ( + {description} + )} + {children} + + + + + {showCancel && ( + + + + )} + + + + ); } const PureAlertContent = React.memo(AlertContent); diff --git a/frontend/src/ui/components/BrowserLink.tsx b/frontend/src/ui/components/BrowserLink.tsx index 30e19c7..0b89275 100644 --- a/frontend/src/ui/components/BrowserLink.tsx +++ b/frontend/src/ui/components/BrowserLink.tsx @@ -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) { - if (!props.href) { - return ; - } + if (!props.href) { + return ; + } - const properties = { ...props }; - properties.href = undefined; - return ( - { - BrowserOpenURL(props.href); - }} - /> - ); + const properties = { ...props }; + properties.href = undefined; + return ( + { + BrowserOpenURL(props.href); + }} + /> + ); } const PureBrowserLink = React.memo(BrowserLink); diff --git a/frontend/src/ui/components/DataTable.tsx b/frontend/src/ui/components/DataTable.tsx index 5b6ed68..fea1418 100644 --- a/frontend/src/ui/components/DataTable.tsx +++ b/frontend/src/ui/components/DataTable.tsx @@ -1,135 +1,135 @@ -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'; +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 { - key: keyof T; - order: 'asc' | 'desc'; + key: keyof T; + order: "asc" | "desc"; } export interface DataTableProps { - data: T[]; - columns: ({ - title: string; - attr?: React.HTMLAttributes; - } & ( - | { - sortable: true; - key: Extract; - } - | { - sortable: false; - key: string; - } - ))[]; - defaultSort: SortingOrder; - rowComponent: (data: { data: T }) => ReactElement; - sort: (key: keyof T) => SortFunction; - keyFunction: (data: T) => string; + data: T[]; + columns: ({ + title: string; + attr?: React.HTMLAttributes; + } & ( + | { + sortable: true; + key: Extract; + } + | { + sortable: false; + key: string; + } + ))[]; + defaultSort: SortingOrder; + rowComponent: (data: { data: T }) => ReactElement; + sort: (key: keyof T) => SortFunction; + 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({ - data, - columns, - defaultSort, - sort, - rowComponent, - keyFunction, + data, + columns, + defaultSort, + sort, + rowComponent, + keyFunction, }: DataTableProps): React.ReactElement { - const [entriesPerPage, setEntriesPerPage] = useState(15); - const [page, setPage] = useState(0); - const [sorting, setSorting] = useState>(defaultSort); + const [entriesPerPage, setEntriesPerPage] = useState(15); + const [page, setPage] = useState(0); + const [sorting, setSorting] = useState>(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 ( - <> - setEntriesPerPage(em)} - onPageChange={(p) => setPage(p - 1)} - /> - - - - {columns.map(({ key, sortable, title, attr }) => ( - - {sortable ? ( - changeSort(key as keyof T)}> - {title} - {sorting.key === key && - (sorting.order === 'asc' ? : )} - - ) : ( - title - )} - - ))} - - - - {paged.map((entry) => ( - - ))} - -
- setEntriesPerPage(em)} - onPageChange={(p) => setPage(p - 1)} - /> - - ); + return ( + <> + setEntriesPerPage(em)} + onPageChange={(p) => setPage(p - 1)} + /> + + + + {columns.map(({ key, sortable, title, attr }) => ( + + {sortable ? ( + changeSort(key as keyof T)}> + {title} + {sorting.key === key && + (sorting.order === "asc" ? : )} + + ) : ( + title + )} + + ))} + + + + {paged.map((entry) => ( + + ))} + +
+ setEntriesPerPage(em)} + onPageChange={(p) => setPage(p - 1)} + /> + + ); } diff --git a/frontend/src/ui/components/DefinitionTable.tsx b/frontend/src/ui/components/DefinitionTable.tsx index d28c8bd..ce33da5 100644 --- a/frontend/src/ui/components/DefinitionTable.tsx +++ b/frontend/src/ui/components/DefinitionTable.tsx @@ -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; + entries: Record; } function DefinitionTable({ entries }: DefinitionTableProps) { - return ( - - - {Object.entries(entries).map(([key, value]) => ( - - {key} - {value} - - ))} - - - ); + return ( + + + {Object.entries(entries).map(([key, value]) => ( + + {key} + {value} + + ))} + + + ); } const PureDefinitionTable = React.memo(DefinitionTable); diff --git a/frontend/src/ui/components/DialogContent.tsx b/frontend/src/ui/components/DialogContent.tsx index a040885..fd30578 100644 --- a/frontend/src/ui/components/DialogContent.tsx +++ b/frontend/src/ui/components/DialogContent.tsx @@ -1,48 +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) { - return ( - - - - {title && ( - - {title} + return ( + + + + {title && ( + + {title} - {closeButton && ( - - - - - - )} - - )} - {description && {description}} - {children} - - - ); + {closeButton && ( + + + + + + )} + + )} + {description && {description}} + {children} + + + ); } const PureDialogContent = React.memo(DialogContent); diff --git a/frontend/src/ui/components/InteractiveAuthDialog.tsx b/frontend/src/ui/components/InteractiveAuthDialog.tsx index e692708..bb012d2 100644 --- a/frontend/src/ui/components/InteractiveAuthDialog.tsx +++ b/frontend/src/ui/components/InteractiveAuthDialog.tsx @@ -1,143 +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 = ['name', 'author', 'verificationCode', 'url', 'icon']; +const appInfoKeys: Array = ["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): AppInfo { - const info: AppInfo = { - name: '', - author: '', - verificationCode: '', - url: '', - icon: '', - }; + const info: AppInfo = { + name: "", + author: "", + verificationCode: "", + url: "", + icon: "", + }; - for (const key of appInfoKeys) { - 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([]); + const { t } = useTranslation(); + const [requests, setRequests] = useState([]); - useEffect(() => { - EventsOn( - 'interactiveAuth', - (uid: number, message: Record, callbackID: string) => { - setRequests([...requests, { uid, info: parseAppInfo(message), callbackID }]); - }, - ); - return () => { - EventsOff('interactiveAuth'); - }; - }); + useEffect(() => { + EventsOn( + "interactiveAuth", + (uid: number, message: Record, 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 }) => ( - - - - {t('pages.interactive-auth.desc-1')} - - {t('pages.interactive-auth.warn-1')} - - {t('pages.interactive-auth.info-present')} - - {info.icon && } - {info.name || t('pages.interactive-auth.unknown-name')} - {info.author && {info.author}} - {info.url && ( - - {info.url} - - )} - - {info.verificationCode && ( - <> - {t('pages.interactive-auth.verification-code')} - {info.verificationCode} - - )} - - - - - - - - ))} - - ); + return ( + <> + {requests.map(({ uid, info, callbackID }) => ( + + + + {t("pages.interactive-auth.desc-1")} + + {t("pages.interactive-auth.warn-1")} + + {t("pages.interactive-auth.info-present")} + + {info.icon && } + {info.name || t("pages.interactive-auth.unknown-name")} + {info.author && {info.author}} + {info.url && ( + + {info.url} + + )} + + {info.verificationCode && ( + <> + {t("pages.interactive-auth.verification-code")} + {info.verificationCode} + + )} + + + + + + + + ))} + + ); } diff --git a/frontend/src/ui/components/Loading.tsx b/frontend/src/ui/components/Loading.tsx index 1234732..ddf1d25 100644 --- a/frontend/src/ui/components/Loading.tsx +++ b/frontend/src/ui/components/Loading.tsx @@ -1,49 +1,49 @@ -import type 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) { - return ( - - - {message} - - ); + return ( + + + {message} + + ); } diff --git a/frontend/src/ui/components/LogViewer.tsx b/frontend/src/ui/components/LogViewer.tsx index 84d4301..b670bc2 100644 --- a/frontend/src/ui/components/LogViewer.tsx +++ b/frontend/src/ui/components/LogViewer.tsx @@ -1,481 +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 type { 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 ( - - {formatTime(data.time)} - {data.message} - - {details.length > 0 ? ( -
{ - setShowDetails(!showDetails); - }} - > - - - ) : null} - {copied ? ( - {t('logging.copied')} - ) : ( - { - void copyToClipboard(); - }} - > - - - )} - - {details.length > 0 && showDetails ? ( - - {details.map(([key, value]) => ( - - {key} - {JSON.stringify(value)} - - ))} - - ) : null} - - ); + return ( + + {formatTime(data.time)} + {data.message} + + {details.length > 0 ? ( + { + setShowDetails(!showDetails); + }} + > + + + ) : null} + {copied ? ( + {t("logging.copied")} + ) : ( + { + void copyToClipboard(); + }} + > + + + )} + + {details.length > 0 && showDetails ? ( + + {details.map(([key, value]) => ( + + {key} + {JSON.stringify(value)} + + ))} + + ) : null} + + ); } -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, - ); + const count = logEntries.reduce( + (acc, entry) => { + if (entry.level in acc) { + acc[entry.level] += 1; + } else { + acc[entry.level] = 1; + } + return acc; + }, + {} as Record, + ); - 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 ( - - - - - {t('logging.dialog-title')} - { - const newFilter = { ...emptyFilter }; - for (const level of values) { - newFilter[level] = true; - } - setFilter(newFilter); - }} - > - {levels.map((level) => ( - - {t(`logging.level.${level}`)} ({count[level] ?? 0}) - - ))} - - - - - - - - - - {filtered.reverse().map((entry) => ( - - ))} - - - - - ); + return ( + + + + + {t("logging.dialog-title")} + { + const newFilter = { ...emptyFilter }; + for (const level of values) { + newFilter[level] = true; + } + setFilter(newFilter); + }} + > + {levels.map((level) => ( + + {t(`logging.level.${level}`)} ({count[level] ?? 0}) + + ))} + + + + + + + + + + {filtered.reverse().map((entry) => ( + + ))} + + + + + ); } function LogViewer() { - const logEntries = useAppSelector((state) => state.logging.messages); - const [activeDialog, setActiveDialog] = useState(null); + const logEntries = useAppSelector((state) => state.logging.messages); + const [activeDialog, setActiveDialog] = useState(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, - ); + const count = logEntries.reduce( + (acc, entry) => { + if (entry.level in acc) { + acc[entry.level] += 1; + } else { + acc[entry.level] = 1; + } + return acc; + }, + {} as Record, + ); - return ( -
- - {levels - .filter((level) => level in count && count[level] > 0) - .map((level) => ( - setActiveDialog(level)}> - {count[level]} - - ))} - + return ( +
+ + {levels + .filter((level) => level in count && count[level] > 0) + .map((level) => ( + setActiveDialog(level)}> + {count[level]} + + ))} + - { - if (!open) { - // Reset dialog status on dialog close - setActiveDialog(null); - } - }} - > - {activeDialog ? ( - - ) : null} - -
- ); + { + if (!open) { + // Reset dialog status on dialog close + setActiveDialog(null); + } + }} + > + {activeDialog ? ( + + ) : null} + +
+ ); } const PureLogViewer = React.memo(LogViewer); diff --git a/frontend/src/ui/components/PageList.tsx b/frontend/src/ui/components/PageList.tsx index 8ca08e4..0df1ba8 100644 --- a/frontend/src/ui/components/PageList.tsx +++ b/frontend/src/ui/components/PageList.tsx @@ -1,153 +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 ( - - - onPageChange(current - 1)} - css={{ flex: 1, '@medium': { flex: 0 } }} - > - ‹ - - = max} - onClick={() => onPageChange(current + 1)} - css={{ flex: 1, '@medium': { flex: 0 } }} - > - › - - onSelectChange(Number(ev.target.value))} - css={{ - textAlign: 'center', - flex: 1, - '@medium': { flex: 0 }, - }} - > - - - - - - - -
{t('pagination.page', { page: current })}
- {current > min ? ( - onPageChange(min)} - > - {min} - - ) : null} - {current > min + 2 ? : null} + const { t } = useTranslation(); + return ( + + + onPageChange(current - 1)} + css={{ flex: 1, "@medium": { flex: 0 } }} + > + ‹ + + = max} + onClick={() => onPageChange(current + 1)} + css={{ flex: 1, "@medium": { flex: 0 } }} + > + › + + onSelectChange(Number(ev.target.value))} + css={{ + textAlign: "center", + flex: 1, + "@medium": { flex: 0 }, + }} + > + + + + + + + +
{t("pagination.page", { page: current })}
+ {current > min ? ( + onPageChange(min)} + > + {min} + + ) : null} + {current > min + 2 ? : null} - {current > min + 1 ? ( - onPageChange(current - 1)} - > - {current - 1} - - ) : null} - - {current} - - {current < max ? ( - onPageChange(current + 1)} - > - {current + 1} - - ) : null} - {current < max - 2 ? : null} - {current < max - 1 ? ( - onPageChange(max)} - > - {max} - - ) : null} -
-
- ); + {current > min + 1 ? ( + onPageChange(current - 1)} + > + {current - 1} + + ) : null} + + {current} + + {current < max ? ( + onPageChange(current + 1)} + > + {current + 1} + + ) : null} + {current < max - 2 ? : null} + {current < max - 1 ? ( + onPageChange(max)} + > + {max} + + ) : null} +
+
+ ); } const PurePageList = React.memo(PageList); diff --git a/frontend/src/ui/components/Sidebar.tsx b/frontend/src/ui/components/Sidebar.tsx index 7d1ae33..a0de54e 100644 --- a/frontend/src/ui/components/Sidebar.tsx +++ b/frontend/src/ui/components/Sidebar.tsx @@ -1,335 +1,335 @@ -import type React from 'react'; -import { 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 ( - - {icon} - {t(title)} - - ); + const { t } = useTranslation(); + const resolved = useResolvedPath(url); + const match = useMatch({ path: resolved.pathname, end: true }); + return ( + + {icon} + {t(title)} + + ); } function parseVersion(semanticVersion: string) { - const [version, prerelease] = semanticVersion.split('-', 2); - const [major, minor, patch] = version.split('.').map((x) => Number.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?.startsWith('v0.0.0'); - const prerelease = !dev && version.includes('-', 6); + 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); - 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(() => { + async function fetchLastVersion() { + try { + const req = await fetch("https://strimertul.stream/update.json"); + const data = (await req.json()) as UpdateInfo; + setLastVersion(prerelease ? data.latest : data.stable); + } catch (e) { + // TODO Report error nicely + console.warn("Failed checking upstream for latest version", e); + } + } - void fetchLastVersion(); - }, [prerelease]); + void fetchLastVersion(); + }, [prerelease]); - return ( - - -
- - - - {APPNAME} - - {version && !dev ? version : t('debug.dev-build')} - - {!dev && version && lastVersion && !hasLatestOrBeta(version, lastVersion.name) && ( - - - {t('menu.messages.update-available')} - - )} -
- {sections.map(({ title: sectionTitle, short, links }) => ( - - - {t(sectionTitle)} - {t(short)} - - {links.map((route) => ( - - ))} - - ))} -
-
- ); + return ( + + +
+ + + + {APPNAME} + + {version && !dev ? version : t("debug.dev-build")} + + {!dev && version && lastVersion && !hasLatestOrBeta(version, lastVersion.name) && ( + + + {t("menu.messages.update-available")} + + )} +
+ {sections.map(({ title: sectionTitle, short, links }) => ( + + + {t(sectionTitle)} + {t(short)} + + {links.map((route) => ( + + ))} + + ))} +
+
+ ); } diff --git a/frontend/src/ui/components/TwitchUserBlock.tsx b/frontend/src/ui/components/TwitchUserBlock.tsx index 12e898b..334d20a 100644 --- a/frontend/src/ui/components/TwitchUserBlock.tsx +++ b/frontend/src/ui/components/TwitchUserBlock.tsx @@ -1,76 +1,76 @@ -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'; +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(null); - const kv = useKilovoltClient(); + const { t } = useTranslation(); + const [user, setUser] = useState(null); + const kv = useKilovoltClient(); - useEffect(() => { - const getUserInfo = async () => { - try { - const res = await GetTwitchLoggedUser(authKey); - setUser(res); - } catch (e) { - setUser({ ok: false, error: (e as Error).message }); - } - }; + useEffect(() => { + const getUserInfo = async () => { + try { + const res = await GetTwitchLoggedUser(authKey); + setUser(res); + } catch (e) { + setUser({ ok: false, error: (e as Error).message }); + } + }; - // Get user info - void getUserInfo(); + // Get user info + void getUserInfo(); - const onKeyChange = () => { - void getUserInfo(); - }; - void kv.subscribeKey(authKey, onKeyChange); - return () => { - void kv.unsubscribeKey(authKey, onKeyChange); - }; - }, [authKey]); + const onKeyChange = () => { + void getUserInfo(); + }; + void kv.subscribeKey(authKey, onKeyChange); + return () => { + void kv.unsubscribeKey(authKey, onKeyChange); + }; + }, [authKey]); - if (user !== null) { - if ('id' in user) { - return ( - - {t('pages.twitch-settings.events.authenticated-as')} - - {user.display_name} - - ); - } - return {noUserMessage}; - } + if (user !== null) { + if ("id" in user) { + return ( + + {t("pages.twitch-settings.events.authenticated-as")} + + {user.display_name} + + ); + } + return {noUserMessage}; + } - return {t('pages.twitch-settings.events.loading-data')}; + return {t("pages.twitch-settings.events.loading-data")}; } diff --git a/frontend/src/ui/components/forms/ControlledInput.tsx b/frontend/src/ui/components/forms/ControlledInput.tsx index c15e56d..07ddd4a 100644 --- a/frontend/src/ui/components/forms/ControlledInput.tsx +++ b/frontend/src/ui/components/forms/ControlledInput.tsx @@ -2,36 +2,36 @@ // Allows to have a input with text manipulation (e.g. sanitation) without // messing with the cursor -import type React from 'react'; -import { useState, useRef, useEffect } from 'react'; +import type React from "react"; +import { useState, useRef, useEffect } from "react"; const ControlledInput = ( - props: React.DetailedHTMLProps, HTMLInputElement>, + props: React.DetailedHTMLProps, HTMLInputElement>, ) => { - const { value, onChange, ...rest } = props; - const [cursor, setCursor] = useState(null); - const ref = useRef(null); + const { value, onChange, ...rest } = props; + const [cursor, setCursor] = useState(null); + const ref = useRef(null); - useEffect(() => { - const input = ref.current; - if (input) { - input.setSelectionRange(cursor, cursor); - } - }, [cursor]); + useEffect(() => { + const input = ref.current; + if (input) { + input.setSelectionRange(cursor, cursor); + } + }, [cursor]); - return ( - { - setCursor(e.target.selectionStart); - if (onChange) { - onChange(e); - } - }} - {...rest} - /> - ); + return ( + { + setCursor(e.target.selectionStart); + if (onChange) { + onChange(e); + } + }} + {...rest} + /> + ); }; export default ControlledInput; diff --git a/frontend/src/ui/components/forms/Interval.tsx b/frontend/src/ui/components/forms/Interval.tsx index 1b1fc4f..afa18fe 100644 --- a/frontend/src/ui/components/forms/Interval.tsx +++ b/frontend/src/ui/components/forms/Interval.tsx @@ -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 ( - <> - - { - const parsedNum = Number.parseInt(ev.target.value, 10); - if (Number.isNaN(parsedNum)) { - return; - } - change(parsedNum, mult); - }} - placeholder="#" - /> - { - const parsedMult = Number.parseInt(ev.target.value, 10); - if (Number.isNaN(parsedMult)) { - return; - } - change(num, parsedMult); - }} - > - {timeUnits.map((unit) => ( - - ))} - - - - ); + return ( + <> + + { + const parsedNum = Number.parseInt(ev.target.value, 10); + if (Number.isNaN(parsedNum)) { + return; + } + change(parsedNum, mult); + }} + placeholder="#" + /> + { + const parsedMult = Number.parseInt(ev.target.value, 10); + if (Number.isNaN(parsedMult)) { + return; + } + change(num, parsedMult); + }} + > + {timeUnits.map((unit) => ( + + ))} + + + + ); } diff --git a/frontend/src/ui/components/forms/MultiInput.tsx b/frontend/src/ui/components/forms/MultiInput.tsx index 0a01390..a113de0 100644 --- a/frontend/src/ui/components/forms/MultiInput.tsx +++ b/frontend/src/ui/components/forms/MultiInput.tsx @@ -1,85 +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(); + const { t } = useTranslation(); - return ( - <> - {value.map((message, index) => ( - - - - {value.length > 1 && ( - - )} - - - ))} - - - - - ); + return ( + <> + {value.map((message, index) => ( + + + + {value.length > 1 && ( + + )} + + + ))} + + + + + ); } const PureMultiInput = React.memo(MultiInput); diff --git a/frontend/src/ui/components/forms/PasswordField.tsx b/frontend/src/ui/components/forms/PasswordField.tsx index 3962cbc..9a068ef 100644 --- a/frontend/src/ui/components/forms/PasswordField.tsx +++ b/frontend/src/ui/components/forms/PasswordField.tsx @@ -1,16 +1,16 @@ -import React from 'react'; +import React from "react"; export interface PasswordFieldProps { - reveal: boolean; + reveal: boolean; } function PasswordField( - props: PasswordFieldProps & - React.DetailedHTMLProps, HTMLInputElement>, + props: PasswordFieldProps & + React.DetailedHTMLProps, HTMLInputElement>, ) { - const subprops = { ...props }; - subprops.reveal = undefined; - return ; + const subprops = { ...props }; + subprops.reveal = undefined; + return ; } const PurePasswordField = React.memo(PasswordField); diff --git a/frontend/src/ui/components/forms/RadioGroup.tsx b/frontend/src/ui/components/forms/RadioGroup.tsx index 81ba9f5..6a89f71 100644 --- a/frontend/src/ui/components/forms/RadioGroup.tsx +++ b/frontend/src/ui/components/forms/RadioGroup.tsx @@ -1,87 +1,87 @@ -import React, { type ReactElement } from 'react'; +import React, { type ReactElement } from "react"; import { - Root, - Item, - Indicator, - type 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 ( - - {props.values.map(({ id, label }) => ( -
- - - - -
- ))} -
- ); + return ( + + {props.values.map(({ id, label }) => ( +
+ + + + +
+ ))} +
+ ); } const PureRadioGroup = React.memo(RadioGroup); diff --git a/frontend/src/ui/components/forms/SaveButton.tsx b/frontend/src/ui/components/forms/SaveButton.tsx index e2c27b9..f75fe60 100644 --- a/frontend/src/ui/components/forms/SaveButton.tsx +++ b/frontend/src/ui/components/forms/SaveButton.tsx @@ -1,32 +1,32 @@ -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'; +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) { - const { t } = useTranslation(); + const { t } = useTranslation(); - switch (props.status?.type) { - case 'success': - return ( - - ); - case 'error': - return ( - - ); - default: - return ; - } + switch (props.status?.type) { + case "success": + return ( + + ); + case "error": + return ( + + ); + default: + return ; + } } const PureSaveButton = React.memo(SaveButton); diff --git a/frontend/src/ui/components/forms/units.tsx b/frontend/src/ui/components/forms/units.tsx index ef663be..aec9ec2 100644 --- a/frontend/src/ui/components/forms/units.tsx +++ b/frontend/src/ui/components/forms/units.tsx @@ -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" }; diff --git a/frontend/src/ui/components/utils/Channels.tsx b/frontend/src/ui/components/utils/Channels.tsx index 691597f..26e79ee 100644 --- a/frontend/src/ui/components/utils/Channels.tsx +++ b/frontend/src/ui/components/utils/Channels.tsx @@ -1,27 +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 = ( - - - - - lists.sr.ht/~ashkeel/strimertul-devel - - - - - - nebula.cafe/discord - - - - - - strimertul@nebula.cafe - - - + + + + + lists.sr.ht/~ashkeel/strimertul-devel + + + + + + nebula.cafe/discord + + + + + + strimertul@nebula.cafe + + + ); export default Channels; diff --git a/frontend/src/ui/components/utils/RevealLink.tsx b/frontend/src/ui/components/utils/RevealLink.tsx index 721df28..1f61200 100644 --- a/frontend/src/ui/components/utils/RevealLink.tsx +++ b/frontend/src/ui/components/utils/RevealLink.tsx @@ -1,29 +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 ( - - ); + const { t } = useTranslation(); + const text = value ? t("form-actions.password-hide") : t("form-actions.password-reveal"); + return ( + + ); } const PureRevealLink = React.memo(RevealLink); diff --git a/frontend/src/ui/components/utils/Scrollbar.tsx b/frontend/src/ui/components/utils/Scrollbar.tsx index 1f1ed3e..324b9a0 100644 --- a/frontend/src/ui/components/utils/Scrollbar.tsx +++ b/frontend/src/ui/components/utils/Scrollbar.tsx @@ -1,62 +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): React.ReactElement { - return ( - - {children} - {vertical ? ( - - - - ) : null} - {horizontal ? ( - - - - ) : null} - - - ); + return ( + + {children} + {vertical ? ( + + + + ) : null} + {horizontal ? ( + + + + ) : null} + + + ); } const PureScrollbar = React.memo(Scrollbar); diff --git a/frontend/src/ui/components/utils/WIPNotice.tsx b/frontend/src/ui/components/utils/WIPNotice.tsx index 29c4505..a7bba73 100644 --- a/frontend/src/ui/components/utils/WIPNotice.tsx +++ b/frontend/src/ui/components/utils/WIPNotice.tsx @@ -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 ( - - - - {t('special.wip.header')} - - - {t('special.wip.text')} - - ); + const { t } = useTranslation(); + return ( + + + + {t("special.wip.header")} + + + {t("special.wip.text")} + + ); } const PureWIP = React.memo(WIP); diff --git a/frontend/src/ui/pages/Dashboard.tsx b/frontend/src/ui/pages/Dashboard.tsx index f731913..64663ac 100644 --- a/frontend/src/ui/pages/Dashboard.tsx +++ b/frontend/src/ui/pages/Dashboard.tsx @@ -1,637 +1,637 @@ import { - CircleIcon, - ExclamationTriangleIcon, - InfoCircledIcon, - UpdateIcon, -} from '@radix-ui/react-icons'; -import { Trans, useTranslation } from 'react-i18next'; -import { type EventSubNotification, EventSubNotificationType, unwrapEvent } from '~/lib/eventSub'; -import { useKilovoltClient, useLiveKey, useModule } from '~/lib/react'; -import { useAppDispatch, useAppSelector } from '~/store'; -import { modules } from '~/store/api/reducer'; -import * as HoverCard from '@radix-ui/react-hover-card'; -import { useEffect, useState } from 'react'; -import type { main } from '@wailsapp/go/models'; -import { GetLastLogs, GetProblems, GetTwitchAuthURL } from '@wailsapp/go/main/App'; -import { BrowserOpenURL } from '@wailsapp/runtime/runtime'; -import { PageContainer, SectionHeader, styled, TextBlock, theme, TooltipContent } from '../theme'; -import BrowserLink from '../components/BrowserLink'; -import Scrollbar from '../components/utils/Scrollbar'; -import RevealLink from '../components/utils/RevealLink'; + CircleIcon, + ExclamationTriangleIcon, + InfoCircledIcon, + UpdateIcon, +} from "@radix-ui/react-icons"; +import { Trans, useTranslation } from "react-i18next"; +import { type EventSubNotification, EventSubNotificationType, unwrapEvent } from "~/lib/eventSub"; +import { useKilovoltClient, useLiveKey, useModule } from "~/lib/react"; +import { useAppDispatch, useAppSelector } from "~/store"; +import { modules } from "~/store/api/reducer"; +import * as HoverCard from "@radix-ui/react-hover-card"; +import { useEffect, useState } from "react"; +import type { main } from "@wailsapp/go/models"; +import { GetLastLogs, GetProblems, GetTwitchAuthURL } from "@wailsapp/go/main/App"; +import { BrowserOpenURL } from "@wailsapp/runtime/runtime"; +import { PageContainer, SectionHeader, styled, TextBlock, theme, TooltipContent } from "../theme"; +import BrowserLink from "../components/BrowserLink"; +import Scrollbar from "../components/utils/Scrollbar"; +import RevealLink from "../components/utils/RevealLink"; interface StreamInfo { - id: string; - user_name: string; - user_login: string; - game_name: string; - title: string; - viewer_count: number; - started_at: string; - language: string; - thumbnail_url: string; + id: string; + user_name: string; + user_login: string; + game_name: string; + title: string; + viewer_count: number; + started_at: string; + language: string; + thumbnail_url: string; } -const StreamBlock = styled('div', { - display: 'grid', - gap: '1rem', - gridTemplateColumns: '160px 1fr', +const StreamBlock = styled("div", { + display: "grid", + gap: "1rem", + gridTemplateColumns: "160px 1fr", }); -const StreamTitle = styled('h3', { - gridRow: 1, - gridColumn: 2, - fontWeight: 400, - margin: 0, - marginTop: '0.5rem', +const StreamTitle = styled("h3", { + gridRow: 1, + gridColumn: 2, + fontWeight: 400, + margin: 0, + marginTop: "0.5rem", }); -const StreamInfo = styled('div', { - gridRow: 2, - gridColumn: 2, - fontWeight: 'bold', - margin: 0, - marginBottom: '0.5rem', +const StreamInfo = styled("div", { + gridRow: 2, + gridColumn: 2, + fontWeight: "bold", + margin: 0, + marginBottom: "0.5rem", }); -const LiveIndicator = styled('div', { - gridRow: '1/3', - gridColumn: 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontWeight: 'bold', - backgroundSize: 'cover', - backgroundPosition: 'center', +const LiveIndicator = styled("div", { + gridRow: "1/3", + gridColumn: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontWeight: "bold", + backgroundSize: "cover", + backgroundPosition: "center", }); const Darken = styled(BrowserLink, { - flex: 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - background: 'rgba(0,0,0,0.5)', - width: '100%', - height: '100%', - gap: '0.5rem', - color: '$red11 !important', - textDecoration: 'none !important', - transition: 'all 0.2s ease-in-out', - '&:hover': { - opacity: 0.5, - }, + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "rgba(0,0,0,0.5)", + width: "100%", + height: "100%", + gap: "0.5rem", + color: "$red11 !important", + textDecoration: "none !important", + transition: "all 0.2s ease-in-out", + "&:hover": { + opacity: 0.5, + }, }); -const EventListContainer = styled('div', { - display: 'flex', - flexDirection: 'column', - gap: '5px', +const EventListContainer = styled("div", { + display: "flex", + flexDirection: "column", + gap: "5px", }); -const TwitchEventContainer = styled('div', { - background: '$gray3', - padding: '8px', - borderRadius: '5px', - display: 'flex', - alignItems: 'center', +const TwitchEventContainer = styled("div", { + background: "$gray3", + padding: "8px", + borderRadius: "5px", + display: "flex", + alignItems: "center", }); -const TwitchEventContent = styled('div', { - flex: 1, +const TwitchEventContent = styled("div", { + flex: 1, }); -const TwitchEventActions = styled('div', { - display: 'flex', - margin: '0 10px', - '& a': { - color: '$gray10', - '&:hover': { - color: '$gray12', - cursor: 'pointer', - }, - }, +const TwitchEventActions = styled("div", { + display: "flex", + margin: "0 10px", + "& a": { + color: "$gray10", + "&:hover": { + color: "$gray12", + cursor: "pointer", + }, + }, }); -const TwitchEventTime = styled('time', { - color: '$gray10', - fontSize: '13px', +const TwitchEventTime = styled("time", { + color: "$gray10", + fontSize: "13px", }); -const UsefulLinksMenu = styled('ul', { - margin: '0', - listStyleType: 'square', - li: { - padding: '3px', - }, +const UsefulLinksMenu = styled("ul", { + margin: "0", + listStyleType: "square", + li: { + padding: "3px", + }, }); const supportedMessages: EventSubNotificationType[] = [ - EventSubNotificationType.Followed, - EventSubNotificationType.CustomRewardRedemptionAdded, - EventSubNotificationType.StreamWentOnline, - EventSubNotificationType.StreamWentOffline, - EventSubNotificationType.ChannelUpdated, - EventSubNotificationType.Raided, - EventSubNotificationType.Cheered, - EventSubNotificationType.Subscription, - EventSubNotificationType.SubscriptionWithMessage, - EventSubNotificationType.SubscriptionGifted, + EventSubNotificationType.Followed, + EventSubNotificationType.CustomRewardRedemptionAdded, + EventSubNotificationType.StreamWentOnline, + EventSubNotificationType.StreamWentOffline, + EventSubNotificationType.ChannelUpdated, + EventSubNotificationType.Raided, + EventSubNotificationType.Cheered, + EventSubNotificationType.Subscription, + EventSubNotificationType.SubscriptionWithMessage, + EventSubNotificationType.SubscriptionGifted, ]; const eventSubKeyFunction = (ev: EventSubNotification) => - `${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(ev.event)}`; + `${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(ev.event)}`; function TwitchEvent({ data }: { data: EventSubNotification }) { - const { t } = useTranslation(); - const client = useAppSelector((state) => state.api.client); - - const replay = () => { - void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, data); - }; - - let content: JSX.Element | string; - const message = unwrapEvent(data); - let date = data.date ? new Date(data.date) : new Date(data.subscription.created_at); - switch (message.type) { - case EventSubNotificationType.Followed: { - content = ( - <> - , - }} - /> - - ); - date = new Date(message.event.followed_at); - break; - } - case EventSubNotificationType.CustomRewardRedemptionAdded: { - content = ( - <> - , - r: , - }} - /> - - ); - date = new Date(message.event.redeemed_at); - break; - } - case EventSubNotificationType.StreamWentOnline: { - content = ( - <> - - - ); - date = new Date(message.event.started_at); - break; - } - case EventSubNotificationType.StreamWentOffline: { - content = ( - <> - - - ); - break; - } - case EventSubNotificationType.ChannelUpdated: { - content = ( - <> - - - ); - break; - } - case EventSubNotificationType.Raided: { - content = ( - <> - , - v: , - }} - /> - - ); - break; - } - case EventSubNotificationType.Cheered: { - content = ( - <> - , - b: , - }} - /> - - ); - break; - } - case EventSubNotificationType.Subscription: - content = ( - <> - , - t: <>, - }} - /> - - ); - break; - case EventSubNotificationType.SubscriptionWithMessage: - content = ( - <> - , - m: <>, - t: <>, - }} - /> - - ); - break; - case EventSubNotificationType.SubscriptionGifted: - content = ( - <> - , - c: <>, - t: <>, - }} - /> - - ); - break; - default: - content = {message.type}; - } - - return ( - - {content} - - {date?.toLocaleTimeString()} - - - { - replay(); - }} - > - - - - - ); + const { t } = useTranslation(); + const client = useAppSelector((state) => state.api.client); + + const replay = () => { + void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, data); + }; + + let content: JSX.Element | string; + const message = unwrapEvent(data); + let date = data.date ? new Date(data.date) : new Date(data.subscription.created_at); + switch (message.type) { + case EventSubNotificationType.Followed: { + content = ( + <> + , + }} + /> + + ); + date = new Date(message.event.followed_at); + break; + } + case EventSubNotificationType.CustomRewardRedemptionAdded: { + content = ( + <> + , + r: , + }} + /> + + ); + date = new Date(message.event.redeemed_at); + break; + } + case EventSubNotificationType.StreamWentOnline: { + content = ( + <> + + + ); + date = new Date(message.event.started_at); + break; + } + case EventSubNotificationType.StreamWentOffline: { + content = ( + <> + + + ); + break; + } + case EventSubNotificationType.ChannelUpdated: { + content = ( + <> + + + ); + break; + } + case EventSubNotificationType.Raided: { + content = ( + <> + , + v: , + }} + /> + + ); + break; + } + case EventSubNotificationType.Cheered: { + content = ( + <> + , + b: , + }} + /> + + ); + break; + } + case EventSubNotificationType.Subscription: + content = ( + <> + , + t: <>, + }} + /> + + ); + break; + case EventSubNotificationType.SubscriptionWithMessage: + content = ( + <> + , + m: <>, + t: <>, + }} + /> + + ); + break; + case EventSubNotificationType.SubscriptionGifted: + content = ( + <> + , + c: <>, + t: <>, + }} + /> + + ); + break; + default: + content = {message.type}; + } + + return ( + + {content} + + {date?.toLocaleTimeString()} + + + { + replay(); + }} + > + + + + + ); } const block = { - content: '""', - display: 'inline-block', - width: '1rem', - height: '0.1rem', - margin: '0 0.5rem', - backgroundColor: '$gray9', + content: '""', + display: "inline-block", + width: "1rem", + height: "0.1rem", + margin: "0 0.5rem", + backgroundColor: "$gray9", }; -const SessionMarker = styled('div', { - textTransform: 'uppercase', - fontWeight: '600', - fontSize: '0.75rem', - color: '$gray11', - margin: '0.25rem', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - '&::before': block, - '&::after': block, +const SessionMarker = styled("div", { + textTransform: "uppercase", + fontWeight: "600", + fontSize: "0.75rem", + color: "$gray11", + margin: "0.25rem", + display: "flex", + alignItems: "center", + justifyContent: "center", + "&::before": block, + "&::after": block, }); interface TwitchEventLogProps { - events: EventSubNotification[]; - dateMarker: number; + events: EventSubNotification[]; + dateMarker: number; } -type EventItem = { type: 'marker' } | { type: 'event'; event: EventSubNotification }; +type EventItem = { type: "marker" } | { type: "event"; event: EventSubNotification }; function TwitchEventLog({ events, dateMarker }: TwitchEventLogProps) { - const { t } = useTranslation(); - - // Filter only supported events and order them by date - const orderedEvents: EventItem[] = events - .filter((ev) => supportedMessages.includes(ev.subscription.type)) - .sort((a, b) => - a.date && b.date - ? Date.parse(b.date) - Date.parse(a.date) - : Date.parse(b.subscription.created_at) - Date.parse(a.subscription.created_at), - ) - .map((ev) => ({ type: 'event', event: ev })); - - // Add marker - if (dateMarker) { - const index = orderedEvents.findIndex((ev) => { - if (ev.type !== 'event') { - return false; - } - return Date.parse(ev.event.date) < dateMarker; - }); - if (index >= 0) { - orderedEvents.splice(index, 0, { type: 'marker' }); - } - } - - return ( - <> - - - - {t('pages.dashboard.twitch-events.header')} - - - - - - - {t('pages.dashboard.twitch-events.warning')} - - - - - {orderedEvents.map((ev) => { - switch (ev.type) { - case 'marker': - return ( - - {t('pages.dashboard.twitch-events.marker')} - - ); - default: - return ; - } - })} - - - - ); + const { t } = useTranslation(); + + // Filter only supported events and order them by date + const orderedEvents: EventItem[] = events + .filter((ev) => supportedMessages.includes(ev.subscription.type)) + .sort((a, b) => + a.date && b.date + ? Date.parse(b.date) - Date.parse(a.date) + : Date.parse(b.subscription.created_at) - Date.parse(a.subscription.created_at), + ) + .map((ev) => ({ type: "event", event: ev })); + + // Add marker + if (dateMarker) { + const index = orderedEvents.findIndex((ev) => { + if (ev.type !== "event") { + return false; + } + return Date.parse(ev.event.date) < dateMarker; + }); + if (index >= 0) { + orderedEvents.splice(index, 0, { type: "marker" }); + } + } + + return ( + <> + + + + {t("pages.dashboard.twitch-events.header")} + + + + + + + {t("pages.dashboard.twitch-events.warning")} + + + + + {orderedEvents.map((ev) => { + switch (ev.type) { + case "marker": + return ( + + {t("pages.dashboard.twitch-events.marker")} + + ); + default: + return ; + } + })} + + + + ); } function TwitchStreamStatus({ info }: { info: StreamInfo }) { - const { t } = useTranslation(); - const [uiConfig, setUiConfig] = useModule(modules.uiConfig); - const dispatch = useAppDispatch(); - return ( - - - - {t('pages.dashboard.live')} - - - {info.title} - - {info.game_name} -{' '} - {t('pages.dashboard.x-viewers', { - num: uiConfig.hideViewers ? '...' : `${info.viewer_count}`, - })}{' '} - { - void dispatch(setUiConfig({ ...uiConfig, hideViewers: !newVal })); - }} - /> - - - ); + const { t } = useTranslation(); + const [uiConfig, setUiConfig] = useModule(modules.uiConfig); + const dispatch = useAppDispatch(); + return ( + + + + {t("pages.dashboard.live")} + + + {info.title} + + {info.game_name} -{" "} + {t("pages.dashboard.x-viewers", { + num: uiConfig.hideViewers ? "..." : `${info.viewer_count}`, + })}{" "} + { + void dispatch(setUiConfig({ ...uiConfig, hideViewers: !newVal })); + }} + /> + + + ); } function TwitchSection() { - const { t } = useTranslation(); - const twitchInfo = useLiveKey('twitch/stream-info'); - const kv = useKilovoltClient(); - const [twitchEvents, setTwitchEvents] = useState([]); - const [oldestLog, setOldestLog] = useState(Date.now()); - - useEffect(() => { - GetLastLogs().then((res) => { - // Get oldest log entry - const oldest = res.reduce((acc, log) => { - const parsedDate = Date.parse(log.time); - return parsedDate < acc ? parsedDate : acc; - }, Date.now()); - - setOldestLog(oldest); - }); - }, []); - - useEffect(() => { - const keyfn = (ev: EventSubNotification) => JSON.stringify(ev); - - const addTwitchEvents = (events: EventSubNotification[]) => { - setTwitchEvents((currentEvents) => { - const allEvents = currentEvents.concat(events); - const eventKeys = allEvents.map(keyfn); - - // Clean up duplicates before setting to state - const updatedEvents = allEvents.filter((ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos); - - return updatedEvents; - }); - }; - - const loadRecentEvents = async () => { - const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/'); - const events = Object.values(keymap).flatMap( - (value) => JSON.parse(value) as EventSubNotification[], - ); - - addTwitchEvents(events); - }; - - void loadRecentEvents(); - - const onKeyChange = (value: string) => { - const event = JSON.parse(value) as EventSubNotification; - if (!supportedMessages.includes(event.subscription.type)) { - return; - } - void addTwitchEvents([event]); - }; - - void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange); - - return () => { - void kv.unsubscribePrefix('twitch/ev/eventsub-event/', onKeyChange); - }; - }, []); - - return ( - <> - {t('pages.dashboard.twitch-status')} - {twitchInfo && twitchInfo.length > 0 ? ( - - ) : ( - {t('pages.dashboard.not-live')} - )} - {twitchEvents ? : null} - - ); + const { t } = useTranslation(); + const twitchInfo = useLiveKey("twitch/stream-info"); + const kv = useKilovoltClient(); + const [twitchEvents, setTwitchEvents] = useState([]); + const [oldestLog, setOldestLog] = useState(Date.now()); + + useEffect(() => { + GetLastLogs().then((res) => { + // Get oldest log entry + const oldest = res.reduce((acc, log) => { + const parsedDate = Date.parse(log.time); + return parsedDate < acc ? parsedDate : acc; + }, Date.now()); + + setOldestLog(oldest); + }); + }, []); + + useEffect(() => { + const keyfn = (ev: EventSubNotification) => JSON.stringify(ev); + + const addTwitchEvents = (events: EventSubNotification[]) => { + setTwitchEvents((currentEvents) => { + const allEvents = currentEvents.concat(events); + const eventKeys = allEvents.map(keyfn); + + // Clean up duplicates before setting to state + const updatedEvents = allEvents.filter((ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos); + + return updatedEvents; + }); + }; + + const loadRecentEvents = async () => { + const keymap = await kv.getKeysByPrefix("twitch/eventsub-history/"); + const events = Object.values(keymap).flatMap( + (value) => JSON.parse(value) as EventSubNotification[], + ); + + addTwitchEvents(events); + }; + + void loadRecentEvents(); + + const onKeyChange = (value: string) => { + const event = JSON.parse(value) as EventSubNotification; + if (!supportedMessages.includes(event.subscription.type)) { + return; + } + void addTwitchEvents([event]); + }; + + void kv.subscribePrefix("twitch/ev/eventsub-event/", onKeyChange); + + return () => { + void kv.unsubscribePrefix("twitch/ev/eventsub-event/", onKeyChange); + }; + }, []); + + return ( + <> + {t("pages.dashboard.twitch-status")} + {twitchInfo && twitchInfo.length > 0 ? ( + + ) : ( + {t("pages.dashboard.not-live")} + )} + {twitchEvents ? : null} + + ); } -const ProblemBlock = styled('div', { - border: '2px solid $gray6', - padding: '0.5rem 1rem', - borderRadius: theme.borderRadius.toolbar, - variants: { - severity: { - warn: { - borderColor: '$yellow6', - backgroundColor: '$yellow3', - color: '$yellow12', - svg: { - color: '$yellow11', - }, - }, - }, - }, - display: 'flex', - gap: '1rem', - alignItems: 'center', - lineHeight: '1.4', - svg: { - marginTop: '0.25rem', - }, - a: { - cursor: 'pointer', - }, +const ProblemBlock = styled("div", { + border: "2px solid $gray6", + padding: "0.5rem 1rem", + borderRadius: theme.borderRadius.toolbar, + variants: { + severity: { + warn: { + borderColor: "$yellow6", + backgroundColor: "$yellow3", + color: "$yellow12", + svg: { + color: "$yellow11", + }, + }, + }, + }, + display: "flex", + gap: "1rem", + alignItems: "center", + lineHeight: "1.4", + svg: { + marginTop: "0.25rem", + }, + a: { + cursor: "pointer", + }, }); function ProblemList() { - const [problems, setProblems] = useState([]); - const { t } = useTranslation(); - const kv = useAppSelector((state) => state.api.client); - - useEffect(() => { - void GetProblems().then(setProblems); - }, []); - - const reauthenticate = async () => { - // Wait for re-auth so we can clear the banner - const onKeyChange = () => { - void GetProblems().then(setProblems); - void kv.unsubscribeKey('twitch/auth-keys', onKeyChange); - }; - void kv.subscribeKey('twitch/auth-keys', onKeyChange); - - const url = await GetTwitchAuthURL('stream'); - BrowserOpenURL(url); - }; - - return ( - <> - {problems.map((p) => { - switch (p.id) { - case 'twitch:eventsub_scope': - return ( - - -
- { - void reauthenticate(); - }} - /> - ), - }} - /> -
-
- ); - default: - return null; - } - })} - - ); + const [problems, setProblems] = useState([]); + const { t } = useTranslation(); + const kv = useAppSelector((state) => state.api.client); + + useEffect(() => { + void GetProblems().then(setProblems); + }, []); + + const reauthenticate = async () => { + // Wait for re-auth so we can clear the banner + const onKeyChange = () => { + void GetProblems().then(setProblems); + void kv.unsubscribeKey("twitch/auth-keys", onKeyChange); + }; + void kv.subscribeKey("twitch/auth-keys", onKeyChange); + + const url = await GetTwitchAuthURL("stream"); + BrowserOpenURL(url); + }; + + return ( + <> + {problems.map((p) => { + switch (p.id) { + case "twitch:eventsub_scope": + return ( + + +
+ { + void reauthenticate(); + }} + /> + ), + }} + /> +
+
+ ); + default: + return null; + } + })} + + ); } export default function Dashboard(): React.ReactElement { - const { t } = useTranslation(); - return ( - - - - {t('pages.dashboard.quick-links')} - -
  • - - {t('pages.dashboard.link-user-guide')} - -
  • -
  • - - {t('pages.dashboard.link-api')} - -
  • -
    -
    - ); + const { t } = useTranslation(); + return ( + + + + {t("pages.dashboard.quick-links")} + +
  • + + {t("pages.dashboard.link-user-guide")} + +
  • +
  • + + {t("pages.dashboard.link-api")} + +
  • +
    +
    + ); } diff --git a/frontend/src/ui/pages/Onboarding.tsx b/frontend/src/ui/pages/Onboarding.tsx index 1a49a14..4676067 100644 --- a/frontend/src/ui/pages/Onboarding.tsx +++ b/frontend/src/ui/pages/Onboarding.tsx @@ -1,668 +1,668 @@ -import { ExclamationTriangleIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; -import { keyframes } from '@stitches/react'; -import { useEffect, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { useModule } from '~/lib/react'; -import { checkTwitchKeys, startAuthFlow } from '~/lib/twitch'; -import { languages } from '~/locale/languages'; -import { useAppDispatch } from '~/store'; -import apiReducer, { modules } from '~/store/api/reducer'; +import { ExclamationTriangleIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; +import { keyframes } from "@stitches/react"; +import { useEffect, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useModule } from "~/lib/react"; +import { checkTwitchKeys, startAuthFlow } from "~/lib/twitch"; +import { languages } from "~/locale/languages"; +import { useAppDispatch } from "~/store"; +import apiReducer, { modules } from "~/store/api/reducer"; // @ts-expect-error Asset import -import spinner from '~/assets/icon-logo.svg'; +import spinner from "~/assets/icon-logo.svg"; -import AlertContent from '../components/AlertContent'; -import BrowserLink from '../components/BrowserLink'; -import DefinitionTable from '../components/DefinitionTable'; -import RevealLink from '../components/utils/RevealLink'; -import Channels from '../components/utils/Channels'; +import AlertContent from "../components/AlertContent"; +import BrowserLink from "../components/BrowserLink"; +import DefinitionTable from "../components/DefinitionTable"; +import RevealLink from "../components/utils/RevealLink"; +import Channels from "../components/utils/Channels"; import { - Button, - ButtonGroup, - Field, - InputBox, - Label, - lightMode, - MultiToggle, - MultiToggleItem, - PageContainer, - PasswordInputBox, - SectionHeader, - styled, - TextBlock, - themes, -} from '../theme'; -import { Alert } from '../theme/alert'; -import TwitchUserBlock from '../components/TwitchUserBlock'; - -const Container = styled('div', { - display: 'flex', - flexDirection: 'column', - width: '100%', + Button, + ButtonGroup, + Field, + InputBox, + Label, + lightMode, + MultiToggle, + MultiToggleItem, + PageContainer, + PasswordInputBox, + SectionHeader, + styled, + TextBlock, + themes, +} from "../theme"; +import { Alert } from "../theme/alert"; +import TwitchUserBlock from "../components/TwitchUserBlock"; + +const Container = styled("div", { + display: "flex", + flexDirection: "column", + width: "100%", }); -const TopBanner = styled('div', { - backgroundColor: '$gray2', - display: 'flex', - width: '100%', - transition: 'all 100ms ease-out', +const TopBanner = styled("div", { + backgroundColor: "$gray2", + display: "flex", + width: "100%", + transition: "all 100ms ease-out", }); const appear = keyframes({ - '0%': { opacity: 0, transform: 'translate(0, 30px)' }, - '100%': { opacity: 1, transform: 'translate(0, 0)' }, + "0%": { opacity: 0, transform: "translate(0, 30px)" }, + "100%": { opacity: 1, transform: "translate(0, 0)" }, }); -const HeroTitle = styled('h1', { - fontSize: '35pt', - fontWeight: 200, - textAlign: 'center', - padding: 0, - margin: 0, - marginBottom: '1em', - '@media (prefers-reduced-motion: no-preference)': { - opacity: 0, - animation: `${appear()} 1s ease-in`, - animationDelay: '1s', - animationFillMode: 'forwards', - }, +const HeroTitle = styled("h1", { + fontSize: "35pt", + fontWeight: 200, + textAlign: "center", + padding: 0, + margin: 0, + marginBottom: "1em", + "@media (prefers-reduced-motion: no-preference)": { + opacity: 0, + animation: `${appear()} 1s ease-in`, + animationDelay: "1s", + animationFillMode: "forwards", + }, }); -const HeroContainer = styled('div', { - height: 'calc(100vh - 110px)', - boxSizing: 'border-box', - justifyContent: 'center', - alignItems: 'center', - display: 'flex', - flexDirection: 'column', - width: '100%', - position: 'relative', - overflow: 'hidden', +const HeroContainer = styled("div", { + height: "calc(100vh - 110px)", + boxSizing: "border-box", + justifyContent: "center", + alignItems: "center", + display: "flex", + flexDirection: "column", + width: "100%", + position: "relative", + overflow: "hidden", }); -const HeroSelector = styled('div', { - top: '10px', - left: '10px', - display: 'flex', - gap: '1rem', - position: 'absolute', - zIndex: '10', +const HeroSelector = styled("div", { + top: "10px", + left: "10px", + display: "flex", + gap: "1rem", + position: "absolute", + zIndex: "10", }); const HeroSelectorItem = styled(MultiToggleItem, { - fontSize: '1rem', - padding: '5px 8px', + fontSize: "1rem", + padding: "5px 8px", }); -const HeroAnimation = styled('div', { - bottom: '-50px', - left: '50%', - position: 'absolute', +const HeroAnimation = styled("div", { + bottom: "-50px", + left: "50%", + position: "absolute", }); -const HeroContent = styled('div', { - display: 'flex', - flexDirection: 'column', - gap: '1rem', - maxWidth: '1000px', - width: '100%', - padding: '0 3rem', - '@media (prefers-reduced-motion: no-preference)': { - opacity: 0, - animation: `${appear()} 1s ease-in`, - animationDelay: '1s', - animationFillMode: 'forwards', - }, - '& p': { margin: 0, padding: 0 }, +const HeroContent = styled("div", { + display: "flex", + flexDirection: "column", + gap: "1rem", + maxWidth: "1000px", + width: "100%", + padding: "0 3rem", + "@media (prefers-reduced-motion: no-preference)": { + opacity: 0, + animation: `${appear()} 1s ease-in`, + animationDelay: "1s", + animationFillMode: "forwards", + }, + "& p": { margin: 0, padding: 0 }, }); const fadeOut = keyframes({ - '0%': { transform: 'translate(10px, 0px) rotate(-80deg)' }, - '100%': { opacity: 0, transform: 'translate(-100px, -800px) rotate(30deg)' }, + "0%": { transform: "translate(10px, 0px) rotate(-80deg)" }, + "100%": { opacity: 0, transform: "translate(-100px, -800px) rotate(30deg)" }, }); -const Spinner = styled('img', { - width: '100px', - position: 'absolute', - '@media (prefers-reduced-motion: no-preference)': { - animation: `${fadeOut()} 2s ease-in`, - animationFillMode: 'forwards', - }, +const Spinner = styled("img", { + width: "100px", + position: "absolute", + "@media (prefers-reduced-motion: no-preference)": { + animation: `${fadeOut()} 2s ease-in`, + animationFillMode: "forwards", + }, }); const StepContainer = styled(PageContainer, { - display: 'flex', - flexDirection: 'column', - paddingTop: '1rem', - '& p': { - margin: '1.5rem 0', - }, + display: "flex", + flexDirection: "column", + paddingTop: "1rem", + "& p": { + margin: "1.5rem 0", + }, }); -const ActionContainer = styled('div', { - flex: 1, - display: 'flex', - justifyContent: 'center', - gap: '1rem', - paddingTop: '1rem', +const ActionContainer = styled("div", { + flex: 1, + display: "flex", + justifyContent: "center", + gap: "1rem", + paddingTop: "1rem", }); -const StepList = styled('nav', { - flex: '1', - display: 'flex', - alignItems: 'center', - padding: '0 1rem', - flexWrap: 'wrap', - flexDirection: 'row', - justifyContent: 'flex-start', - [`.${lightMode} &`]: { - borderBottom: '1px solid $gray6', - backgroundColor: '$gray2', - }, +const StepList = styled("nav", { + flex: "1", + display: "flex", + alignItems: "center", + padding: "0 1rem", + flexWrap: "wrap", + flexDirection: "row", + justifyContent: "flex-start", + [`.${lightMode} &`]: { + borderBottom: "1px solid $gray6", + backgroundColor: "$gray2", + }, }); -const StepName = styled('div', { - padding: '0.5rem', - color: '$gray10', - '&:not(:last-child)::after': { - color: '$gray10', - content: '›', - margin: '0 0 0 1rem', - }, - display: 'none', - '@thin': { - display: 'inherit', - }, - variants: { - status: { - active: { - color: '$gray12', - display: 'inherit', - - [`.${lightMode} &`]: { - fontWeight: '500', - }, - }, - }, - interaction: { - clickable: { - cursor: 'pointer', - }, - }, - }, +const StepName = styled("div", { + padding: "0.5rem", + color: "$gray10", + "&:not(:last-child)::after": { + color: "$gray10", + content: "›", + margin: "0 0 0 1rem", + }, + display: "none", + "@thin": { + display: "inherit", + }, + variants: { + status: { + active: { + color: "$gray12", + display: "inherit", + + [`.${lightMode} &`]: { + fontWeight: "500", + }, + }, + }, + interaction: { + clickable: { + cursor: "pointer", + }, + }, + }, }); enum OnboardingSteps { - Landing = 0, - TwitchIntegration = 1, - TwitchEvents = 2, - Done = 999, + Landing = 0, + TwitchIntegration = 1, + TwitchEvents = 2, + Done = 999, } const steps = [ - OnboardingSteps.Landing, - OnboardingSteps.TwitchIntegration, - OnboardingSteps.TwitchEvents, - OnboardingSteps.Done, + OnboardingSteps.Landing, + OnboardingSteps.TwitchIntegration, + OnboardingSteps.TwitchEvents, + OnboardingSteps.Done, ]; const stepI18n = { - [OnboardingSteps.Landing]: 'pages.onboarding.sections.landing', - [OnboardingSteps.TwitchIntegration]: 'pages.onboarding.sections.twitch-config', - [OnboardingSteps.TwitchEvents]: 'pages.onboarding.sections.twitch-events', - [OnboardingSteps.Done]: 'pages.onboarding.sections.done', + [OnboardingSteps.Landing]: "pages.onboarding.sections.landing", + [OnboardingSteps.TwitchIntegration]: "pages.onboarding.sections.twitch-config", + [OnboardingSteps.TwitchEvents]: "pages.onboarding.sections.twitch-events", + [OnboardingSteps.Done]: "pages.onboarding.sections.done", }; const maxKeys = languages.reduce((current, it) => Math.max(current, it.keys), 0); type TestResult = { open: boolean; error?: Error }; -const TwitchStepList = styled('ul', { - lineHeight: '1.5', - listStyleType: 'none', - listStylePosition: 'outside', +const TwitchStepList = styled("ul", { + lineHeight: "1.5", + listStyleType: "none", + listStylePosition: "outside", }); -const TwitchStep = styled('li', { - marginBottom: '0.5rem', - paddingLeft: '1rem', - '&::marker': { - color: '$teal11', - content: '▧', - display: 'inline-block', - marginLeft: '-0.5rem', - }, +const TwitchStep = styled("li", { + marginBottom: "0.5rem", + paddingLeft: "1rem", + "&::marker": { + color: "$teal11", + content: "▧", + display: "inline-block", + marginLeft: "-0.5rem", + }, }); function TwitchIntegrationStep() { - const { t } = useTranslation(); - const [httpConfig] = useModule(modules.httpConfig); - const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig); - const [uiConfig, setUiConfig] = useModule(modules.uiConfig); - const dispatch = useAppDispatch(); - const [revealClientSecret, setRevealClientSecret] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState({ - open: false, - }); - - const checkCredentials = async () => { - setTesting(true); - if (twitchConfig) { - try { - await checkTwitchKeys(twitchConfig.api_client_id, twitchConfig.api_client_secret); - void dispatch( - setTwitchConfig({ - ...twitchConfig, - enabled: true, - }), - ); - void dispatch( - setUiConfig({ - ...uiConfig, - onboardingStatus: uiConfig.onboardingStatus + 1, - }), - ); - } catch (e: unknown) { - setTestResult({ open: true, error: e as Error }); - } - } - setTesting(false); - }; - - function skipTwitch() { - void dispatch( - setUiConfig({ - ...uiConfig, - onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1, - }), - ); - } - - const allFields = - (twitchConfig?.api_client_id?.length > 0 ?? false) && - (twitchConfig?.api_client_secret?.length > 0 ?? false); - - return ( -
    { - void dispatch(setTwitchConfig(twitchConfig)); - ev.preventDefault(); - }} - > - {t('pages.onboarding.twitch-p1')} - - - - {' '} - - https://dev.twitch.tv/console/apps/create - - - - - {t('pages.twitch-settings.apiguide-3')} - - 0 - ? httpConfig.bind - : `localhost${httpConfig?.bind ?? ':4337'}` - }/twitch/callback`, - [t('pages.twitch-settings.app-category')]: 'Broadcasting Suite', - }} - /> - - - - {'str1 '} - str2 - - - - - - - dispatch( - apiReducer.actions.twitchConfigChanged({ - ...twitchConfig, - api_client_id: ev.target.value, - }), - ) - } - /> - - - - - - dispatch( - apiReducer.actions.twitchConfigChanged({ - ...twitchConfig, - api_client_secret: ev.target.value, - }), - ) - } - /> - - {t('pages.onboarding.twitch-p2')} - - - - - { - setTestResult({ ...testResult, open: val }); - }} - > - { - setTestResult({ ...testResult, open: false }); - }} - /> - -
    - ); + const { t } = useTranslation(); + const [httpConfig] = useModule(modules.httpConfig); + const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig); + const [uiConfig, setUiConfig] = useModule(modules.uiConfig); + const dispatch = useAppDispatch(); + const [revealClientSecret, setRevealClientSecret] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState({ + open: false, + }); + + const checkCredentials = async () => { + setTesting(true); + if (twitchConfig) { + try { + await checkTwitchKeys(twitchConfig.api_client_id, twitchConfig.api_client_secret); + void dispatch( + setTwitchConfig({ + ...twitchConfig, + enabled: true, + }), + ); + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingStatus: uiConfig.onboardingStatus + 1, + }), + ); + } catch (e: unknown) { + setTestResult({ open: true, error: e as Error }); + } + } + setTesting(false); + }; + + function skipTwitch() { + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1, + }), + ); + } + + const allFields = + (twitchConfig?.api_client_id?.length > 0 ?? false) && + (twitchConfig?.api_client_secret?.length > 0 ?? false); + + return ( +
    { + void dispatch(setTwitchConfig(twitchConfig)); + ev.preventDefault(); + }} + > + {t("pages.onboarding.twitch-p1")} + + + + {" "} + + https://dev.twitch.tv/console/apps/create + + + + + {t("pages.twitch-settings.apiguide-3")} + + 0 + ? httpConfig.bind + : `localhost${httpConfig?.bind ?? ":4337"}` + }/twitch/callback`, + [t("pages.twitch-settings.app-category")]: "Broadcasting Suite", + }} + /> + + + + {"str1 "} + str2 + + + + + + + dispatch( + apiReducer.actions.twitchConfigChanged({ + ...twitchConfig, + api_client_id: ev.target.value, + }), + ) + } + /> + + + + + + dispatch( + apiReducer.actions.twitchConfigChanged({ + ...twitchConfig, + api_client_secret: ev.target.value, + }), + ) + } + /> + + {t("pages.onboarding.twitch-p2")} + + + + + { + setTestResult({ ...testResult, open: val }); + }} + > + { + setTestResult({ ...testResult, open: false }); + }} + /> + +
    + ); } function TwitchEventsStep() { - const { t } = useTranslation(); - const [uiConfig, setUiConfig] = useModule(modules.uiConfig); - const dispatch = useAppDispatch(); - - const finishStep = async () => { - await dispatch( - setUiConfig({ - ...uiConfig, - onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1, - }), - ); - }; - - return ( -
    - {t('pages.onboarding.twitch-ev-p1')} - {t('pages.twitch-settings.events.auth-message')} - - - - {t('pages.twitch-settings.events.current-status')} - - {t('pages.onboarding.twitch-ev-p3')} - -
    - ); + const { t } = useTranslation(); + const [uiConfig, setUiConfig] = useModule(modules.uiConfig); + const dispatch = useAppDispatch(); + + const finishStep = async () => { + await dispatch( + setUiConfig({ + ...uiConfig, + onboardingStatus: steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1, + }), + ); + }; + + return ( +
    + {t("pages.onboarding.twitch-ev-p1")} + {t("pages.twitch-settings.events.auth-message")} + + + + {t("pages.twitch-settings.events.current-status")} + + {t("pages.onboarding.twitch-ev-p3")} + +
    + ); } function DoneStep() { - const { t } = useTranslation(); - const [uiConfig, setUiConfig] = useModule(modules.uiConfig); - const dispatch = useAppDispatch(); - - const done = () => { - void dispatch( - setUiConfig({ - ...uiConfig, - onboardingDone: true, - }), - ); - }; - - return ( -
    - {t('pages.onboarding.done-header')} - {t('pages.onboarding.done-p1')} - {t('pages.onboarding.done-p2')} - {Channels} - {t('pages.onboarding.done-p3')} - -
    - ); + const { t } = useTranslation(); + const [uiConfig, setUiConfig] = useModule(modules.uiConfig); + const dispatch = useAppDispatch(); + + const done = () => { + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingDone: true, + }), + ); + }; + + return ( +
    + {t("pages.onboarding.done-header")} + {t("pages.onboarding.done-p1")} + {t("pages.onboarding.done-p2")} + {Channels} + {t("pages.onboarding.done-p3")} + +
    + ); } export default function OnboardingPage() { - const [t, i18n] = useTranslation(); - const [animationItems, setAnimationItems] = useState([]); - const [uiConfig, setUiConfig] = useModule(modules.uiConfig); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - - const currentStep = steps[uiConfig?.onboardingStatus || 0]; - const landing = currentStep === OnboardingSteps.Landing; - - // Skip onboarding if we've already done it - const onboardingDone = uiConfig?.onboardingDone; - useEffect(() => { - if (onboardingDone) { - navigate('/'); - } - }, [onboardingDone]); - - const skip = () => { - void dispatch( - setUiConfig({ - ...uiConfig, - onboardingDone: true, - }), - ); - }; - - useEffect(() => { - const spinners = new Array(30).fill(spinner as string); - setAnimationItems( - spinners.map((url, i) => ( - - )), - ); - }, []); - - let currentStepBody: JSX.Element = null; - switch (currentStep) { - case OnboardingSteps.Landing: - currentStepBody = ( - - - - - ); - break; - case OnboardingSteps.TwitchIntegration: - currentStepBody = ; - break; - case OnboardingSteps.TwitchEvents: - currentStepBody = ; - break; - case OnboardingSteps.Done: - currentStepBody = ; - break; - } - - return ( - - - {landing ? ( - - - { - void dispatch(setUiConfig({ ...uiConfig, language: newLang })); - localStorage.setItem('language', newLang); - }} - > - {languages.map((lang) => ( - - {lang.name} - {lang.keys < maxKeys ? : null} - - ))} - - - { - void dispatch(setUiConfig({ ...uiConfig, theme: newTheme })); - localStorage.setItem('theme', newTheme); - }} - > - {themes.map((theme) => ( - - {t(`pages.uiconfig.themes.${theme}`)} - - ))} - - - {animationItems} - {t('pages.onboarding.welcome-header')} - - {t('pages.onboarding.welcome-p1')} - - - ), - }} - /> - - {t('pages.onboarding.welcome-p2')} - - - ) : ( - - {steps.map((step) => ( - { - // Can't skip ahead - if (step >= currentStep) { - return; - } - void dispatch( - setUiConfig({ - ...uiConfig, - onboardingStatus: steps.findIndex((val) => val === step) ?? 0, - }), - ); - }} - > - {t(stepI18n[step])} - - ))} - - )} - - {currentStepBody} - - ); + const [t, i18n] = useTranslation(); + const [animationItems, setAnimationItems] = useState([]); + const [uiConfig, setUiConfig] = useModule(modules.uiConfig); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const currentStep = steps[uiConfig?.onboardingStatus || 0]; + const landing = currentStep === OnboardingSteps.Landing; + + // Skip onboarding if we've already done it + const onboardingDone = uiConfig?.onboardingDone; + useEffect(() => { + if (onboardingDone) { + navigate("/"); + } + }, [onboardingDone]); + + const skip = () => { + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingDone: true, + }), + ); + }; + + useEffect(() => { + const spinners = new Array(30).fill(spinner as string); + setAnimationItems( + spinners.map((url, i) => ( + + )), + ); + }, []); + + let currentStepBody: JSX.Element = null; + switch (currentStep) { + case OnboardingSteps.Landing: + currentStepBody = ( + + + + + ); + break; + case OnboardingSteps.TwitchIntegration: + currentStepBody = ; + break; + case OnboardingSteps.TwitchEvents: + currentStepBody = ; + break; + case OnboardingSteps.Done: + currentStepBody = ; + break; + } + + return ( + + + {landing ? ( + + + { + void dispatch(setUiConfig({ ...uiConfig, language: newLang })); + localStorage.setItem("language", newLang); + }} + > + {languages.map((lang) => ( + + {lang.name} + {lang.keys < maxKeys ? : null} + + ))} + + + { + void dispatch(setUiConfig({ ...uiConfig, theme: newTheme })); + localStorage.setItem("theme", newTheme); + }} + > + {themes.map((theme) => ( + + {t(`pages.uiconfig.themes.${theme}`)} + + ))} + + + {animationItems} + {t("pages.onboarding.welcome-header")} + + {t("pages.onboarding.welcome-p1")} + + + ), + }} + /> + + {t("pages.onboarding.welcome-p2")} + + + ) : ( + + {steps.map((step) => ( + { + // Can't skip ahead + if (step >= currentStep) { + return; + } + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingStatus: steps.findIndex((val) => val === step) ?? 0, + }), + ); + }} + > + {t(stepI18n[step])} + + ))} + + )} + + {currentStepBody} + + ); } diff --git a/frontend/src/ui/pages/loyalty/LoyaltyConfig.tsx b/frontend/src/ui/pages/loyalty/LoyaltyConfig.tsx index bcd7899..07d2304 100644 --- a/frontend/src/ui/pages/loyalty/LoyaltyConfig.tsx +++ b/frontend/src/ui/pages/loyalty/LoyaltyConfig.tsx @@ -1,173 +1,173 @@ -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 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 = useTimedStatus(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 ( - - - {t('pages.loyalty-settings.title')} - {t('pages.loyalty-settings.subtitle')} - {t('pages.loyalty-settings.note')} - - - { - void dispatch( - setConfig({ - ...config, - enabled: !!ev, - }), - ); - }} - id="enable" - > - {active && } - - - - - -
    { - e.preventDefault(); - if (!(e.target as HTMLFormElement).checkValidity()) { - return; - } - void dispatch(setConfig(config)); - }} - > - - - { - void dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...config, - currency: e.target.value, - }), - ); - }} - /> - {t('pages.loyalty-settings.currency-name-hint')} - + return ( + + + {t("pages.loyalty-settings.title")} + {t("pages.loyalty-settings.subtitle")} + {t("pages.loyalty-settings.note")} + + + { + void dispatch( + setConfig({ + ...config, + enabled: !!ev, + }), + ); + }} + id="enable" + > + {active && } + + + + + + { + e.preventDefault(); + if (!(e.target as HTMLFormElement).checkValidity()) { + return; + } + void dispatch(setConfig(config)); + }} + > + + + { + void dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...config, + currency: e.target.value, + }), + ); + }} + /> + {t("pages.loyalty-settings.currency-name-hint")} + - - - - { - const intNum = Number.parseInt(e.target.value, 10); - if (Number.isNaN(intNum)) { - return; - } - void dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...config, - points: { - ...config.points, - amount: intNum, - }, - }), - ); - }} - /> -
    {t('pages.loyalty-settings.every')}
    - { - void dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...(config ?? {}), - points: { - ...config?.points, - interval, - }, - }), - ); - }} - active={active && !busy} - min={5} - required={true} - /> -
    -
    + + + + { + const intNum = Number.parseInt(e.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + void dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...config, + points: { + ...config.points, + amount: intNum, + }, + }), + ); + }} + /> +
    {t("pages.loyalty-settings.every")}
    + { + void dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...(config ?? {}), + points: { + ...config?.points, + interval, + }, + }), + ); + }} + active={active && !busy} + min={5} + required={true} + /> +
    +
    - - - { - 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, - }, - }), - ); - }} - /> - {t('pages.loyalty-settings.bonus-points-hint')} - + + + { + 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, + }, + }), + ); + }} + /> + {t("pages.loyalty-settings.bonus-points-hint")} + - - -
    - ); + + +
    + ); } diff --git a/frontend/src/ui/pages/loyalty/LoyaltyQueue.tsx b/frontend/src/ui/pages/loyalty/LoyaltyQueue.tsx index 871e37a..b80af84 100644 --- a/frontend/src/ui/pages/loyalty/LoyaltyQueue.tsx +++ b/frontend/src/ui/pages/loyalty/LoyaltyQueue.tsx @@ -1,391 +1,391 @@ -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 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 ( - - {data.date.toLocaleString()} - {data.username} - {data.reward?.name} - {data.request_text} - - - - - - - - ); + return ( + + {data.date.toLocaleString()} + {data.username} + {data.reward?.name} + {data.request_text} + + + + + + + + ); } 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 && ( - `${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} - /> - )) || {t('pages.loyalty-queue.no-redeems')}} - - ); + return ( + <> + {(data.length > 0 && ( + `${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} + /> + )) || {t("pages.loyalty-queue.no-redeems")}} + + ); } function UserList() { - const { t } = useTranslation(); - const users = useUserPoints(); - const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const users = useUserPoints(); + const dispatch = useAppDispatch(); - const [currentEntry, setCurrentEntry] = useState(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(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 => { - 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 => { + 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 }) => ( - - {data.username} - {data.points} - - - - - ); + const UserListRow = ({ data }: { data: UserEntry }) => ( + + {data.username} + {data.points} + + + + + ); - return ( - <> - setGivePointDialog({ ...givePointDialog, open: state })} - > - -
    { - e.preventDefault(); - if ((e.target as HTMLFormElement).checkValidity()) { - void dispatch( - setUserPoints({ - ...givePointDialog, - user: givePointDialog.user.toLowerCase(), - relative: true, - }), - ); - setGivePointDialog({ ...givePointDialog, open: false }); - } - }} - > - - - - setGivePointDialog({ - ...givePointDialog, - user: e.target.value, - }) - } - /> - - - - - setGivePointDialog({ - ...givePointDialog, - points: Number.parseInt(e.target.value, 10), - }) - } - /> - - - - - -
    -
    -
    - setCurrentEntry(state ? currentEntry : null)} - > - -
    { - e.preventDefault(); - if ((e.target as HTMLFormElement).checkValidity()) { - void dispatch( - setUserPoints({ - user: currentEntry.username.toLowerCase(), - points: currentEntry.points, - relative: false, - }), - ); - setCurrentEntry(null); - } - }} - > - - - - - - - - setCurrentEntry({ - ...currentEntry, - points: Number.parseInt(e.target.value, 10), - }) - } - /> - - - - - -
    -
    -
    - - - - setUsernameFilter(e.target.value)} - /> - - - {(filtered.length > 0 && ( - 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} - /> - )) || {t('pages.loyalty-queue.no-users')}} - - ); + return ( + <> + setGivePointDialog({ ...givePointDialog, open: state })} + > + +
    { + e.preventDefault(); + if ((e.target as HTMLFormElement).checkValidity()) { + void dispatch( + setUserPoints({ + ...givePointDialog, + user: givePointDialog.user.toLowerCase(), + relative: true, + }), + ); + setGivePointDialog({ ...givePointDialog, open: false }); + } + }} + > + + + + setGivePointDialog({ + ...givePointDialog, + user: e.target.value, + }) + } + /> + + + + + setGivePointDialog({ + ...givePointDialog, + points: Number.parseInt(e.target.value, 10), + }) + } + /> + + + + + +
    +
    +
    + setCurrentEntry(state ? currentEntry : null)} + > + +
    { + e.preventDefault(); + if ((e.target as HTMLFormElement).checkValidity()) { + void dispatch( + setUserPoints({ + user: currentEntry.username.toLowerCase(), + points: currentEntry.points, + relative: false, + }), + ); + setCurrentEntry(null); + } + }} + > + + + + + + + + setCurrentEntry({ + ...currentEntry, + points: Number.parseInt(e.target.value, 10), + }) + } + /> + + + + + +
    +
    +
    + + + + setUsernameFilter(e.target.value)} + /> + + + {(filtered.length > 0 && ( + 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} + /> + )) || {t("pages.loyalty-queue.no-users")}} + + ); } export default function LoyaltyQueuePage(): React.ReactElement { - const { t } = useTranslation(); + const { t } = useTranslation(); - return ( - - - {t('pages.loyalty-queue.title')} - {t('pages.loyalty-queue.subtitle')} - - - - {t('pages.loyalty-queue.queue-tab')} - {t('pages.loyalty-queue.users-tab')} - - - - - - - - - - ); + return ( + + + {t("pages.loyalty-queue.title")} + {t("pages.loyalty-queue.subtitle")} + + + + {t("pages.loyalty-queue.queue-tab")} + {t("pages.loyalty-queue.users-tab")} + + + + + + + + + + ); } diff --git a/frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx b/frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx index 2d9728e..8dd8900 100644 --- a/frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx +++ b/frontend/src/ui/pages/loyalty/Rewards/GoalsTab.tsx @@ -1,348 +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 type { 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 ( - - - - {item.image && ( - - )} - - - {item.name} ({name}) - - - {item.contributed} / {item.total} {currency} ( - {Math.round((item.contributed / item.total) * 100)}%) - - - - - - - - - - (onDelete ? onDelete() : null)} - /> - - - - - {item.description} - - ); + return ( + + + + {item.image && ( + + )} + + + {item.name} ({name}) + + + {item.contributed} / {item.total} {currency} ( + {Math.round((item.contributed / item.total) * 100)}%) + + + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + {item.description} + + ); } 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 ( - <> - setDialogGoal({ ...dialogGoal, open: state })} - > - -
    { - 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 }); - }} - > - - - { - 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(''); - } - }} - /> - {t('pages.loyalty-rewards.goal-id-hint')} - - - - { - setDialogGoal({ - ...dialogGoal, - goal: { - ...dialogGoal?.goal, - name: e.target.value, - }, - }); - }} - /> - {t('pages.loyalty-rewards.goal-name-hint')} - - - - { - setDialogGoal({ - ...dialogGoal, - goal: { - ...dialogGoal?.goal, - image: e.target.value, - }, - }); - }} - /> - - - - - - - - { - setDialogGoal({ - ...dialogGoal, - goal: { - ...dialogGoal?.goal, - total: Number.parseInt(e.target.value, 10), - }, - }); - }} - /> - - - - - -
    -
    -
    - - - - setFilter(e.target.value)} - /> - - - - {goals && goals.length > 0 ? ( - goals - ?.filter( - (r) => - r.name.toLowerCase().includes(filterLC) || - r.id.toLowerCase().includes(filterLC) || - r.description.toLowerCase().includes(filterLC), - ) - .map((r) => ( - - setDialogGoal({ - open: true, - new: false, - goal: r, - }) - } - onDelete={() => deleteGoal(r.id)} - onToggle={() => toggleGoal(r.id)} - /> - )) - ) : ( - {t('pages.loyalty-rewards.no-goals')} - )} - - - ); + return ( + <> + setDialogGoal({ ...dialogGoal, open: state })} + > + +
    { + 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 }); + }} + > + + + { + 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(""); + } + }} + /> + {t("pages.loyalty-rewards.goal-id-hint")} + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + name: e.target.value, + }, + }); + }} + /> + {t("pages.loyalty-rewards.goal-name-hint")} + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + image: e.target.value, + }, + }); + }} + /> + + + + + + + + { + setDialogGoal({ + ...dialogGoal, + goal: { + ...dialogGoal?.goal, + total: Number.parseInt(e.target.value, 10), + }, + }); + }} + /> + + + + + +
    +
    +
    + + + + setFilter(e.target.value)} + /> + + + + {goals && goals.length > 0 ? ( + goals + ?.filter( + (r) => + r.name.toLowerCase().includes(filterLC) || + r.id.toLowerCase().includes(filterLC) || + r.description.toLowerCase().includes(filterLC), + ) + .map((r) => ( + + setDialogGoal({ + open: true, + new: false, + goal: r, + }) + } + onDelete={() => deleteGoal(r.id)} + onToggle={() => toggleGoal(r.id)} + /> + )) + ) : ( + {t("pages.loyalty-rewards.no-goals")} + )} + + + ); } diff --git a/frontend/src/ui/pages/loyalty/Rewards/Page.tsx b/frontend/src/ui/pages/loyalty/Rewards/Page.tsx index 5a9b370..5cffeaa 100644 --- a/frontend/src/ui/pages/loyalty/Rewards/Page.tsx +++ b/frontend/src/ui/pages/loyalty/Rewards/Page.tsx @@ -1,38 +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 ( - - - {t('pages.loyalty-rewards.title')} - {t('pages.loyalty-rewards.subtitle')} - - - - {t('pages.loyalty-rewards.rewards-tab')} - {t('pages.loyalty-rewards.goals-tab')} - - - - - - - - - - ); + return ( + + + {t("pages.loyalty-rewards.title")} + {t("pages.loyalty-rewards.subtitle")} + + + + {t("pages.loyalty-rewards.rewards-tab")} + {t("pages.loyalty-rewards.goals-tab")} + + + + + + + + + + ); } diff --git a/frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx b/frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx index 9da9520..be9278b 100644 --- a/frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx +++ b/frontend/src/ui/pages/loyalty/Rewards/RewardsTab.tsx @@ -1,413 +1,413 @@ -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 { 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 ( - - - - {item.image && ( - {item.name} - )} - - - {item.name} ({name}) - - - {item.price} {currency} - - - - - - - - - - (onDelete ? onDelete() : null)} - /> - - - - - {item.description} - - ); + return ( + + + + {item.image && ( + {item.name} + )} + + + {item.name} ({name}) + + + {item.price} {currency} + + + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + {item.description} + + ); } 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 ( - <> - setDialogReward({ ...dialogReward, open: state })} - > - -
    { - 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 }); - }} - > - - - { - 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(''); - } - }} - /> - {t('pages.loyalty-rewards.reward-id-hint')} - - - - { - setDialogReward({ - ...dialogReward, - reward: { - ...dialogReward?.reward, - name: e.target.value, - }, - }); - }} - /> - {t('pages.loyalty-rewards.reward-name-hint')} - - - - { - setDialogReward({ - ...dialogReward, - reward: { - ...dialogReward?.reward, - image: e.target.value, - }, - }); - }} - /> - - - - - - - - { - setDialogReward({ - ...dialogReward, - reward: { - ...dialogReward?.reward, - price: Number.parseInt(e.target.value, 10), - }, - }); - }} - /> - - - - - { - setDialogReward({ - ...dialogReward, - reward: { - ...dialogReward?.reward, - cooldown, - }, - }); - }} - /> - - - - - { - setRequiredInfo({ - ...requiredInfo, - enabled: !!e, - }); - }} - > - {requiredInfo.enabled && } - - - - { - setRequiredInfo({ ...requiredInfo, text: e.target.value }); - }} - /> - - - - - -
    -
    -
    - - - - setFilter(e.target.value)} - /> - - - - {rewards && rewards.length > 0 ? ( - rewards - ?.filter( - (r) => - r.name.toLowerCase().includes(filterLC) || - r.id.toLowerCase().includes(filterLC) || - r.description.toLowerCase().includes(filterLC), - ) - .map((r) => ( - - setDialogReward({ - open: true, - new: false, - reward: r, - }) - } - onDelete={() => deleteReward(r.id)} - onToggle={() => toggleReward(r.id)} - /> - )) - ) : ( - {t('pages.loyalty-rewards.no-rewards')} - )} - - - ); + return ( + <> + setDialogReward({ ...dialogReward, open: state })} + > + +
    { + 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 }); + }} + > + + + { + 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(""); + } + }} + /> + {t("pages.loyalty-rewards.reward-id-hint")} + + + + { + setDialogReward({ + ...dialogReward, + reward: { + ...dialogReward?.reward, + name: e.target.value, + }, + }); + }} + /> + {t("pages.loyalty-rewards.reward-name-hint")} + + + + { + setDialogReward({ + ...dialogReward, + reward: { + ...dialogReward?.reward, + image: e.target.value, + }, + }); + }} + /> + + + + + + + + { + setDialogReward({ + ...dialogReward, + reward: { + ...dialogReward?.reward, + price: Number.parseInt(e.target.value, 10), + }, + }); + }} + /> + + + + + { + setDialogReward({ + ...dialogReward, + reward: { + ...dialogReward?.reward, + cooldown, + }, + }); + }} + /> + + + + + { + setRequiredInfo({ + ...requiredInfo, + enabled: !!e, + }); + }} + > + {requiredInfo.enabled && } + + + + { + setRequiredInfo({ ...requiredInfo, text: e.target.value }); + }} + /> + + + + + +
    +
    +
    + + + + setFilter(e.target.value)} + /> + + + + {rewards && rewards.length > 0 ? ( + rewards + ?.filter( + (r) => + r.name.toLowerCase().includes(filterLC) || + r.id.toLowerCase().includes(filterLC) || + r.description.toLowerCase().includes(filterLC), + ) + .map((r) => ( + + setDialogReward({ + open: true, + new: false, + reward: r, + }) + } + onDelete={() => deleteReward(r.id)} + onToggle={() => toggleReward(r.id)} + /> + )) + ) : ( + {t("pages.loyalty-rewards.no-rewards")} + )} + + + ); } diff --git a/frontend/src/ui/pages/loyalty/Rewards/theme.tsx b/frontend/src/ui/pages/loyalty/Rewards/theme.tsx index 1a20ff5..6ec1805 100644 --- a/frontend/src/ui/pages/loyalty/Rewards/theme.tsx +++ b/frontend/src/ui/pages/loyalty/Rewards/theme.tsx @@ -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", }); diff --git a/frontend/src/ui/pages/system/Debug.tsx b/frontend/src/ui/pages/system/Debug.tsx index 605f26e..30f01e3 100644 --- a/frontend/src/ui/pages/system/Debug.tsx +++ b/frontend/src/ui/pages/system/Debug.tsx @@ -1,168 +1,168 @@ -import type React from 'react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } 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(null); - const api = useAppSelector((state) => 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(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 ( - - {t('pages.debug.disclaimer-header')} - {t('pages.debug.big-ass-warning')} - - - ); - } - return ( - - - {t('pages.debug.title')} - - - - - - - - -
    { - e.preventDefault(); - if ((e.target as HTMLFormElement).checkValidity()) { - void performRead(); - } - }} - > - - - - setReadKey(e.target.value)} - id="read-key" - css={{ flex: '1' }} - /> - - - - -
    + if (!warningDismissed) { + return ( + + {t("pages.debug.disclaimer-header")} + {t("pages.debug.big-ass-warning")} + + + ); + } + return ( + + + {t("pages.debug.title")} + + + + + + + + +
    { + e.preventDefault(); + if ((e.target as HTMLFormElement).checkValidity()) { + void performRead(); + } + }} + > + + + + setReadKey(e.target.value)} + id="read-key" + css={{ flex: "1" }} + /> + + + + +
    -
    { - e.preventDefault(); - if ((e.target as HTMLFormElement).checkValidity()) { - void performWrite(); - } - }} - > - - - - setWriteKey(e.target.value)} - id="write-key" - css={{ flex: '1' }} - /> - - - - - {writeErrorMsg && {writeErrorMsg}} - -
    -
    - ); +
    { + e.preventDefault(); + if ((e.target as HTMLFormElement).checkValidity()) { + void performWrite(); + } + }} + > + + + + setWriteKey(e.target.value)} + id="write-key" + css={{ flex: "1" }} + /> + + + + + {writeErrorMsg && {writeErrorMsg}} + +
    +
    + ); } diff --git a/frontend/src/ui/pages/system/Extensions.tsx b/frontend/src/ui/pages/system/Extensions.tsx index b57fd41..d9599b8 100644 --- a/frontend/src/ui/pages/system/Extensions.tsx +++ b/frontend/src/ui/pages/system/Extensions.tsx @@ -1,618 +1,618 @@ -import Editor, { type Monaco, useMonaco } from '@monaco-editor/react'; +import Editor, { type Monaco, useMonaco } from "@monaco-editor/react"; import { - ExclamationTriangleIcon, - InfoCircledIcon, - InputIcon, - PilcrowIcon, - PlusIcon, -} from '@radix-ui/react-icons'; -import type React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { blankTemplate } from '~/lib/extensions/extension'; -import { kilovoltDefinition } from '~/lib/extensions/helpers'; -import { parseExtensionMetadata } from '~/lib/extensions/metadata'; -import { ExtensionStatus } from '~/lib/extensions/types'; -import slug from '~/lib/slug'; -import * as HoverCard from '@radix-ui/react-hover-card'; -import { useAppDispatch, useAppSelector } from '~/store'; + ExclamationTriangleIcon, + InfoCircledIcon, + InputIcon, + PilcrowIcon, + PlusIcon, +} from "@radix-ui/react-icons"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { blankTemplate } from "~/lib/extensions/extension"; +import { kilovoltDefinition } from "~/lib/extensions/helpers"; +import { parseExtensionMetadata } from "~/lib/extensions/metadata"; +import { ExtensionStatus } from "~/lib/extensions/types"; +import slug from "~/lib/slug"; +import * as HoverCard from "@radix-ui/react-hover-card"; +import { useAppDispatch, useAppSelector } from "~/store"; import extensionsReducer, { - currentFile, - type ExtensionEntry, - isUnsaved, - removeExtension, - renameExtension, - saveCurrentExtension, - saveExtension, - startExtension, - stopExtension, -} from '~/store/extensions/reducer'; -import { useModule } from '~/lib/react'; -import { modules } from '~/store/api/reducer'; -import AlertContent from '../../components/AlertContent'; -import DialogContent from '../../components/DialogContent'; -import Loading from '../../components/Loading'; + currentFile, + type ExtensionEntry, + isUnsaved, + removeExtension, + renameExtension, + saveCurrentExtension, + saveExtension, + startExtension, + stopExtension, +} from "~/store/extensions/reducer"; +import { useModule } from "~/lib/react"; +import { modules } from "~/store/api/reducer"; +import AlertContent from "../../components/AlertContent"; +import DialogContent from "../../components/DialogContent"; +import Loading from "../../components/Loading"; import { - Button, - ComboBox, - ControlledInputBox, - Dialog, - DialogActions, - Field, - FlexRow, - getTheme, - InputBox, - Label, - MultiButton, - PageContainer, - PageHeader, - PageTitle, - styled, - TabButton, - TabContainer, - TabContent, - TabList, -} from '../../theme'; -import { Alert, AlertTrigger } from '../../theme/alert'; + Button, + ComboBox, + ControlledInputBox, + Dialog, + DialogActions, + Field, + FlexRow, + getTheme, + InputBox, + Label, + MultiButton, + PageContainer, + PageHeader, + PageTitle, + styled, + TabButton, + TabContainer, + TabContent, + TabList, +} from "../../theme"; +import { Alert, AlertTrigger } from "../../theme/alert"; -const ExtensionRow = styled('article', { - marginBottom: '0.4rem', - backgroundColor: '$gray2', - margin: '0.5rem 0', - padding: '0.3rem 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 ExtensionRow = styled("article", { + marginBottom: "0.4rem", + backgroundColor: "$gray2", + margin: "0.5rem 0", + padding: "0.3rem 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 ExtensionName = styled('div', { - flex: '1', - display: 'flex', - gap: '0.5rem', - alignItems: 'baseline', +const ExtensionName = styled("div", { + flex: "1", + display: "flex", + gap: "0.5rem", + alignItems: "baseline", }); -const ExtensionStatusNote = styled('div', { - textTransform: 'uppercase', - fontSize: '10pt', - color: '$gray10', - variants: { - color: { - active: { - color: '$teal11', - }, - error: { - color: '$red9', - }, - }, - }, +const ExtensionStatusNote = styled("div", { + textTransform: "uppercase", + fontSize: "10pt", + color: "$gray10", + variants: { + color: { + active: { + color: "$teal11", + }, + error: { + color: "$red9", + }, + }, + }, }); -const ExtensionActions = styled('div', { - display: 'flex', - alignItems: 'center', - gap: '0.25rem', +const ExtensionActions = styled("div", { + display: "flex", + alignItems: "center", + gap: "0.25rem", }); const ExtensionInfoCard = styled(HoverCard.Content, { - borderRadius: 6, - display: 'flex', - padding: '0.5rem', - width: '300px', - gap: '0.5rem', - flexDirection: 'column', - border: '2px solid $gray6', - backgroundColor: '$gray2', - alignItems: 'flex-start', - boxShadow: '0px 5px 20px rgba(0,0,0,0.4)', - animationDuration: '400ms', - animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)', - willChange: 'transform, opacity', + borderRadius: 6, + display: "flex", + padding: "0.5rem", + width: "300px", + gap: "0.5rem", + flexDirection: "column", + border: "2px solid $gray6", + backgroundColor: "$gray2", + alignItems: "flex-start", + boxShadow: "0px 5px 20px rgba(0,0,0,0.4)", + animationDuration: "400ms", + animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", + willChange: "transform, opacity", }); const isRunning = (status: ExtensionStatus) => - status === ExtensionStatus.Running || status === ExtensionStatus.Finished; + status === ExtensionStatus.Running || status === ExtensionStatus.Finished; const colorByStatus = (status: ExtensionStatus) => { - if (isRunning(status)) { - return 'active'; - } - if (status === ExtensionStatus.Error) { - return 'error'; - } - return null; + if (isRunning(status)) { + return "active"; + } + if (status === ExtensionStatus.Error) { + return "error"; + } + return null; }; type ExtensionListItemProps = { - enabled: boolean; - entry: ExtensionEntry; - status: ExtensionStatus; - error?: Error | ErrorEvent; - onEdit: () => void; - onRemove: () => void; - onToggleEnable: () => void; - onToggleStatus: () => void; + enabled: boolean; + entry: ExtensionEntry; + status: ExtensionStatus; + error?: Error | ErrorEvent; + onEdit: () => void; + onRemove: () => void; + onToggleEnable: () => void; + onToggleStatus: () => void; }; function ExtensionListItem(props: ExtensionListItemProps) { - const { t } = useTranslation(); - const metadata = parseExtensionMetadata(props.entry.source); - const version = useAppSelector((state) => state.server.version?.release); - const isDev = version?.startsWith('v0.0.0'); - const showIncompatibleWarning = !isDev && version && version < `v${metadata.apiversion}`; + const { t } = useTranslation(); + const metadata = parseExtensionMetadata(props.entry.source); + const version = useAppSelector((state) => state.server.version?.release); + const isDev = version?.startsWith("v0.0.0"); + const showIncompatibleWarning = !isDev && version && version < `v${metadata.apiversion}`; - return ( - - - - {metadata ? ( - - - - {props.entry.name} - - - - - -
    - {metadata.name || 'Unnamed extension'}{' '} - {metadata.version ? `v${metadata.version}` : null} - {metadata.author ? ` by ${metadata.author}` : null} -
    - {metadata.description ? {metadata.description} : null} -
    -
    -
    - ) : ( - props.entry.name - )} - {props.enabled ? ( - <> - - {t(`pages.extensions.statuses.${props.status}`)} - - {showIncompatibleWarning ? ( - - - - - - {t('pages.extensions.incompatible-body', { - version: metadata.apiversion, - appversion: version, - })} - - - ) : null} - {props.error ? ( - - - - - - {props.error.message} - - - ) : null} - - ) : null} -
    - - - - {props.enabled ? ( - <> - - - ) : null} + return ( + + + + {metadata ? ( + + + + {props.entry.name} + + + + + +
    + {metadata.name || "Unnamed extension"}{" "} + {metadata.version ? `v${metadata.version}` : null} + {metadata.author ? ` by ${metadata.author}` : null} +
    + {metadata.description ? {metadata.description} : null} +
    +
    +
    + ) : ( + props.entry.name + )} + {props.enabled ? ( + <> + + {t(`pages.extensions.statuses.${props.status}`)} + + {showIncompatibleWarning ? ( + + + + + + {t("pages.extensions.incompatible-body", { + version: metadata.apiversion, + appversion: version, + })} + + + ) : null} + {props.error ? ( + + + + + + {props.error.message} + + + ) : null} + + ) : null} +
    + + + + {props.enabled ? ( + <> + + + ) : null} - - - - - - props.onRemove()} - /> - - - -
    -
    - ); + + + + + + props.onRemove()} + /> + +
    +
    +
    +
    + ); } interface ExtensionListProps { - onNew: () => void; - onEdit: (name: string) => void; + onNew: () => void; + onEdit: (name: string) => void; } function ExtensionList({ onNew, onEdit }: ExtensionListProps) { - const extensions = useAppSelector((state) => state.extensions); - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const [filter, setFilter] = useState(''); - const filterLC = filter.toLowerCase(); + const extensions = useAppSelector((state) => state.extensions); + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const filterLC = filter.toLowerCase(); - return ( - - - {t('pages.extensions.title')} - - - - - setFilter(e.target.value)} - /> - - - {Object.values(extensions.installed) - ?.filter((r) => r.name.toLowerCase().includes(filterLC)) - .map((e) => ( - onEdit(e.name)} - onRemove={() => { - // Toggle enabled status - void dispatch(removeExtension(e.name)); - }} - onToggleEnable={() => { - // Toggle enabled status - void dispatch( - saveExtension({ - ...e, - options: { - ...e.options, - enabled: !e.options.enabled, - }, - }), - ); - }} - onToggleStatus={() => { - if (isRunning(extensions.status[e.name])) { - void dispatch(stopExtension(e.name)); - } else { - void dispatch(startExtension(e.name)); - } - }} - /> - ))} - - ); + return ( + + + {t("pages.extensions.title")} + + + + + setFilter(e.target.value)} + /> + + + {Object.values(extensions.installed) + ?.filter((r) => r.name.toLowerCase().includes(filterLC)) + .map((e) => ( + onEdit(e.name)} + onRemove={() => { + // Toggle enabled status + void dispatch(removeExtension(e.name)); + }} + onToggleEnable={() => { + // Toggle enabled status + void dispatch( + saveExtension({ + ...e, + options: { + ...e.options, + enabled: !e.options.enabled, + }, + }), + ); + }} + onToggleStatus={() => { + if (isRunning(extensions.status[e.name])) { + void dispatch(stopExtension(e.name)); + } else { + void dispatch(startExtension(e.name)); + } + }} + /> + ))} + + ); } const EditorButton = styled(Button, { - borderRadius: '0', - border: 'none', - '&:disabled': { - border: '0', - backgroundColor: '$gray5', - color: '$gray9', - cursor: 'not-allowed', - }, + borderRadius: "0", + border: "none", + "&:disabled": { + border: "0", + backgroundColor: "$gray5", + color: "$gray9", + cursor: "not-allowed", + }, }); const EditorDropdown = styled(ComboBox, { - borderRadius: '0', - border: 'none', - padding: '0.3rem 0.5rem', - fontSize: '0.9rem', + borderRadius: "0", + border: "none", + padding: "0.3rem 0.5rem", + fontSize: "0.9rem", }); const setupLibrary = (monaco: Monaco, source: string, url: string) => { - // Prevent model from being added twice - const models = monaco.editor.getModels(); - if (models.some((lt) => lt.uri.toString() === url)) { - return; - } + // Prevent model from being added twice + const models = monaco.editor.getModels(); + if (models.some((lt) => lt.uri.toString() === url)) { + return; + } - monaco.languages.typescript.typescriptDefaults.addExtraLib(source, url); - monaco.editor.createModel(source, 'typescript', monaco.Uri.parse(url)); + monaco.languages.typescript.typescriptDefaults.addExtraLib(source, url); + monaco.editor.createModel(source, "typescript", monaco.Uri.parse(url)); }; function ExtensionEditor() { - const [dialogRename, setDialogRename] = useState({ open: false, name: '' }); - const extensions = useAppSelector((state) => state.extensions); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const monaco = useMonaco(); - const editor = useRef(null); + const [dialogRename, setDialogRename] = useState({ open: false, name: "" }); + const extensions = useAppSelector((state) => state.extensions); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const monaco = useMonaco(); + const editor = useRef(null); - useEffect(() => { - if (monaco) { - monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true); - monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - diagnosticCodesToIgnore: [1375, 2792], - }); - monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ - target: monaco.languages.typescript.ScriptTarget.ES2020, - module: monaco.languages.typescript.ModuleKind.ESNext, - allowNonTsExtensions: true, - }); - setupLibrary(monaco, kilovoltDefinition, 'ts:index.d.ts'); - } - }, [monaco]); + useEffect(() => { + if (monaco) { + monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true); + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + diagnosticCodesToIgnore: [1375, 2792], + }); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.ES2020, + module: monaco.languages.typescript.ModuleKind.ESNext, + allowNonTsExtensions: true, + }); + setupLibrary(monaco, kilovoltDefinition, "ts:index.d.ts"); + } + }, [monaco]); - // Normally you can't navigate here without this being set but there is an instant - // where you can and it messes up the dropdown, so don't render anything for that - // split second - if (!extensions.editorCurrentFile) { - return <>; - } + // Normally you can't navigate here without this being set but there is an instant + // where you can and it messes up the dropdown, so don't render anything for that + // split second + if (!extensions.editorCurrentFile) { + return <>; + } - const isModified = isUnsaved(extensions); - const currentFileSource = currentFile(extensions); + const isModified = isUnsaved(extensions); + const currentFileSource = currentFile(extensions); - return ( -
    - - { - void dispatch(extensionsReducer.actions.editorSelectedFile(ev.target.value)); - }} - css={{ flex: '1' }} - > - {Object.values(extensions.installed) - .filter((ext) => !(ext.name in extensions.unsaved)) // Hide those with changes - .map((ext) => ( - - ))} - {Object.keys(extensions.unsaved).map((ext) => ( - - ))} - - setDialogRename({ open: true, name: extensions.editorCurrentFile })} - > - - - { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - editor.current.getAction('editor.action.formatDocument').run(); - }} - > - - - { - void dispatch(saveCurrentExtension()); - }} - > - {t('form-actions.save')} - - - { - void dispatch(extensionsReducer.actions.extensionSourceChanged(ev)); - }} - onMount={(instance, m) => { - editor.current = instance; - instance.addCommand( - // eslint-disable-next-line no-bitwise - m.KeyMod.CtrlCmd | m.KeyCode.KeyS, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - () => { - void dispatch(saveCurrentExtension()); - }, - ); - }} - /> - setDialogRename({ ...dialogRename, open: state })} - > - -
    { - e.preventDefault(); - if (!(e.target as HTMLFormElement).checkValidity()) { - return; - } + return ( +
    + + { + void dispatch(extensionsReducer.actions.editorSelectedFile(ev.target.value)); + }} + css={{ flex: "1" }} + > + {Object.values(extensions.installed) + .filter((ext) => !(ext.name in extensions.unsaved)) // Hide those with changes + .map((ext) => ( + + ))} + {Object.keys(extensions.unsaved).map((ext) => ( + + ))} + + setDialogRename({ open: true, name: extensions.editorCurrentFile })} + > + + + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + editor.current.getAction("editor.action.formatDocument").run(); + }} + > + + + { + void dispatch(saveCurrentExtension()); + }} + > + {t("form-actions.save")} + + + { + void dispatch(extensionsReducer.actions.extensionSourceChanged(ev)); + }} + onMount={(instance, m) => { + editor.current = instance; + instance.addCommand( + // eslint-disable-next-line no-bitwise + m.KeyMod.CtrlCmd | m.KeyCode.KeyS, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + () => { + void dispatch(saveCurrentExtension()); + }, + ); + }} + /> + setDialogRename({ ...dialogRename, open: state })} + > + + { + e.preventDefault(); + if (!(e.target as HTMLFormElement).checkValidity()) { + return; + } - // Only rename if it changed - if (extensions.editorCurrentFile !== dialogRename.name) { - void dispatch( - renameExtension({ - from: extensions.editorCurrentFile, - to: dialogRename.name, - }), - ); - } + // Only rename if it changed + if (extensions.editorCurrentFile !== dialogRename.name) { + void dispatch( + renameExtension({ + from: extensions.editorCurrentFile, + to: dialogRename.name, + }), + ); + } - setDialogRename({ ...dialogRename, open: false }); - }} - > - - - { - setDialogRename({ - ...dialogRename, - name: e.target.value, - }); - if (Object.values(extensions.installed).find((r) => r.name === e.target.value)) { - (e.target as HTMLInputElement).setCustomValidity( - t('pages.extensions.name-already-in-use'), - ); - } else { - (e.target as HTMLInputElement).setCustomValidity(''); - } - }} - /> - - - - - - - - -
    - ); + setDialogRename({ ...dialogRename, open: false }); + }} + > + + + { + setDialogRename({ + ...dialogRename, + name: e.target.value, + }); + if (Object.values(extensions.installed).find((r) => r.name === e.target.value)) { + (e.target as HTMLInputElement).setCustomValidity( + t("pages.extensions.name-already-in-use"), + ); + } else { + (e.target as HTMLInputElement).setCustomValidity(""); + } + }} + /> + + + + + + +
    +
    +
    + ); } export default function ExtensionsPage(): React.ReactElement { - const [uiConfig] = useModule(modules.uiConfig); - const { t } = useTranslation(); - const extensions = useAppSelector((state) => state.extensions); - const dispatch = useAppDispatch(); - const [currentTab, setCurrentTab] = useState('list'); + const [uiConfig] = useModule(modules.uiConfig); + const { t } = useTranslation(); + const extensions = useAppSelector((state) => state.extensions); + const dispatch = useAppDispatch(); + const [currentTab, setCurrentTab] = useState("list"); - const newClicked = () => { - // Create new empty file - let defaultName = ''; - do { - defaultName = slug(); - } while (defaultName in extensions.installed || defaultName in extensions.unsaved); + const newClicked = () => { + // Create new empty file + let defaultName = ""; + do { + defaultName = slug(); + } while (defaultName in extensions.installed || defaultName in extensions.unsaved); - // Add as draft - dispatch( - extensionsReducer.actions.extensionDrafted({ - name: defaultName, - source: blankTemplate(defaultName), - options: { enabled: false }, - }), - ); + // Add as draft + dispatch( + extensionsReducer.actions.extensionDrafted({ + name: defaultName, + source: blankTemplate(defaultName), + options: { enabled: false }, + }), + ); - // Set it as current file in editor - dispatch(extensionsReducer.actions.editorSelectedFile(defaultName)); - setCurrentTab('editor'); - }; + // Set it as current file in editor + dispatch(extensionsReducer.actions.editorSelectedFile(defaultName)); + setCurrentTab("editor"); + }; - const editClicked = (name: string) => { - // Set it as current file in editor - dispatch(extensionsReducer.actions.editorSelectedFile(name)); - setCurrentTab('editor'); - }; + const editClicked = (name: string) => { + // Set it as current file in editor + dispatch(extensionsReducer.actions.editorSelectedFile(name)); + setCurrentTab("editor"); + }; - if (!extensions.ready) { - const theme = getTheme(uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark'); + if (!extensions.ready) { + const theme = getTheme(uiConfig?.theme ?? localStorage.getItem("theme") ?? "dark"); - return ( - - - {t('pages.extensions.title')} - - - - ); - } + return ( + + + {t("pages.extensions.title")} + + + + ); + } - return ( - setCurrentTab(newval)}> - - {t('pages.extensions.tab-manage')} - - {t('pages.extensions.tab-editor')} - - - - newClicked()} onEdit={(name) => editClicked(name)} /> - - - - - - ); + return ( + setCurrentTab(newval)}> + + {t("pages.extensions.tab-manage")} + + {t("pages.extensions.tab-editor")} + + + + newClicked()} onEdit={(name) => editClicked(name)} /> + + + + + + ); } diff --git a/frontend/src/ui/pages/system/ServerSettings.tsx b/frontend/src/ui/pages/system/ServerSettings.tsx index 74294d5..91083f3 100644 --- a/frontend/src/ui/pages/system/ServerSettings.tsx +++ b/frontend/src/ui/pages/system/ServerSettings.tsx @@ -1,132 +1,132 @@ -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 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 = useTimedStatus(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 ( - - - {t('pages.http.title')} - -
    { - ev.preventDefault(); - if (insecureKilovolt) { - setShowKilovoltWarning(true); - return; - } - void dispatch(setServerConfig(serverConfig)); - }} - > - - - - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - bind: e.target.value, - }), - ) - } - /> - {t('pages.http.bind-help')} - - - {' '} - { - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - kv_password: e.target.value, - }), - ); - }} - /> - {t('pages.http.kilovolt-placeholder')} - - - - - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - path: e.target.value, - }), - ) - } - value={serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''} - /> - - {t('pages.http.static-help', { - url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`, - })} - - - - - { - void dispatch(setServerConfig(serverConfig)); - }} - /> - - -
    - ); + return ( + + + {t("pages.http.title")} + +
    { + ev.preventDefault(); + if (insecureKilovolt) { + setShowKilovoltWarning(true); + return; + } + void dispatch(setServerConfig(serverConfig)); + }} + > + + + + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + bind: e.target.value, + }), + ) + } + /> + {t("pages.http.bind-help")} + + + {" "} + { + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + kv_password: e.target.value, + }), + ); + }} + /> + {t("pages.http.kilovolt-placeholder")} + + + + + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + path: e.target.value, + }), + ) + } + value={serverConfig?.enable_static_server ? serverConfig?.path ?? "" : ""} + /> + + {t("pages.http.static-help", { + url: `http://${serverConfig?.bind ?? "localhost:4337"}/static/`, + })} + + + + + { + void dispatch(setServerConfig(serverConfig)); + }} + /> + + +
    + ); } diff --git a/frontend/src/ui/pages/system/Strimertul.tsx b/frontend/src/ui/pages/system/Strimertul.tsx index 94af260..352cf8f 100644 --- a/frontend/src/ui/pages/system/Strimertul.tsx +++ b/frontend/src/ui/pages/system/Strimertul.tsx @@ -1,38 +1,38 @@ -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'; +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%, @@ -47,112 +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 ( - - - - {APPNAME} - -
    - {t('pages.strimertul.need-help')} - - {t('pages.strimertul.need-help-p1')} - - {Channels} -
    -
    - {t('pages.strimertul.credits-header')} - - Sonic_Chan, - }} - /> - -
    -
    - {t('pages.strimertul.license-header')} - - - GNU Affero General Public License v3.0 - - ), - }} - /> - -
    -
    - ); + return ( + + + + {APPNAME} + +
    + {t("pages.strimertul.need-help")} + + {t("pages.strimertul.need-help-p1")} + + {Channels} +
    +
    + {t("pages.strimertul.credits-header")} + + Sonic_Chan, + }} + /> + +
    +
    + {t("pages.strimertul.license-header")} + + + GNU Affero General Public License v3.0 + + ), + }} + /> + +
    +
    + ); } diff --git a/frontend/src/ui/pages/system/UISettingsPage.tsx b/frontend/src/ui/pages/system/UISettingsPage.tsx index ed49b74..06a3ee3 100644 --- a/frontend/src/ui/pages/system/UISettingsPage.tsx +++ b/frontend/src/ui/pages/system/UISettingsPage.tsx @@ -1,93 +1,93 @@ -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 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); 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 ( - - - {t('pages.uiconfig.title')} - - - - { - void dispatch(setUiConfig({ ...uiConfig, language: value })); - localStorage.setItem('language', value); - }} - values={languages.map((lang) => ({ - id: lang.code, - label: ( - - {lang.name}{' '} - {lang.keys < maxKeys ? ( - - {t('pages.uiconfig.partial-translation')} ( - {((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/{maxKeys}) - - ) : null} - - ), - }))} - /> - - - - { - void dispatch(setUiConfig({ ...uiConfig, theme: value })); - localStorage.setItem('theme', value); - }} - values={themes.map((theme) => ({ - id: theme, - label: t(`pages.uiconfig.themes.${theme}`), - }))} - /> - - - - ); + return ( + + + {t("pages.uiconfig.title")} + + + + { + void dispatch(setUiConfig({ ...uiConfig, language: value })); + localStorage.setItem("language", value); + }} + values={languages.map((lang) => ({ + id: lang.code, + label: ( + + {lang.name}{" "} + {lang.keys < maxKeys ? ( + + {t("pages.uiconfig.partial-translation")} ( + {((lang.keys / maxKeys) * 100).toFixed(1)}% - {lang.keys}/{maxKeys}) + + ) : null} + + ), + }))} + /> + + + + { + void dispatch(setUiConfig({ ...uiConfig, theme: value })); + localStorage.setItem("theme", value); + }} + values={themes.map((theme) => ({ + id: theme, + label: t(`pages.uiconfig.themes.${theme}`), + }))} + /> + + + + ); } diff --git a/frontend/src/ui/pages/twitch/ChatAlerts.tsx b/frontend/src/ui/pages/twitch/ChatAlerts.tsx index 636bd8f..6ae8315 100644 --- a/frontend/src/ui/pages/twitch/ChatAlerts.tsx +++ b/frontend/src/ui/pages/twitch/ChatAlerts.tsx @@ -1,275 +1,275 @@ -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 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 = useTimedStatus(loadStatus.save); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [alerts, setAlerts, loadStatus] = useModule(modules.twitchChatAlerts); + const status = useTimedStatus(loadStatus.save); - return ( - -
    { - void dispatch(setAlerts(alerts)); - ev.preventDefault(); - }} - > - - {t('pages.alerts.title')} - {t('pages.alerts.desc')} - - - - {t('pages.alerts.events.follow')} - {t('pages.alerts.events.subscription')} - {t('pages.alerts.events.gift-sub')} - {t('pages.alerts.events.raid')} - {t('pages.alerts.events.cheer')} - - - - - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - follow: { - ...alerts.follow, - enabled: !!ev, - }, - }), - ); - }} - id="follow-enabled" - > - {alerts?.follow?.enabled && } - + return ( + + { + void dispatch(setAlerts(alerts)); + ev.preventDefault(); + }} + > + + {t("pages.alerts.title")} + {t("pages.alerts.desc")} + + + + {t("pages.alerts.events.follow")} + {t("pages.alerts.events.subscription")} + {t("pages.alerts.events.gift-sub")} + {t("pages.alerts.events.raid")} + {t("pages.alerts.events.cheer")} + + + + + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + follow: { + ...alerts.follow, + enabled: !!ev, + }, + }), + ); + }} + id="follow-enabled" + > + {alerts?.follow?.enabled && } + - - - - - - {t('pages.alerts.msg-info')} - { - dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - follow: { ...alerts.follow, messages }, - }), - ); - }} - /> - - - - - - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - subscription: { - ...alerts.subscription, - enabled: !!ev, - }, - }), - ); - }} - id="subscription-enabled" - > - - {alerts?.subscription?.enabled && } - - + + + + + + {t("pages.alerts.msg-info")} + { + dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + follow: { ...alerts.follow, messages }, + }), + ); + }} + /> + + + + + + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + subscription: { + ...alerts.subscription, + enabled: !!ev, + }, + }), + ); + }} + id="subscription-enabled" + > + + {alerts?.subscription?.enabled && } + + - - - - - - {t('pages.alerts.msg-info')} - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - subscription: { ...alerts.subscription, messages }, - }), - ); - }} - /> - - + + + + + + {t("pages.alerts.msg-info")} + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + subscription: { ...alerts.subscription, messages }, + }), + ); + }} + /> + + - - - - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - gift_sub: { - ...alerts.gift_sub, - enabled: !!ev, - }, - }), - ); - }} - id="gift_sub-enabled" - > - - {alerts?.gift_sub?.enabled && } - - + + + + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + gift_sub: { + ...alerts.gift_sub, + enabled: !!ev, + }, + }), + ); + }} + id="gift_sub-enabled" + > + + {alerts?.gift_sub?.enabled && } + + - - - - - - {t('pages.alerts.msg-info')} - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - gift_sub: { ...alerts.gift_sub, messages }, - }), - ); - }} - /> - - + + + + + + {t("pages.alerts.msg-info")} + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + gift_sub: { ...alerts.gift_sub, messages }, + }), + ); + }} + /> + + - - - - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - raid: { - ...alerts.raid, - enabled: !!ev, - }, - }), - ); - }} - id="raid-enabled" - > - {alerts?.raid?.enabled && } - + + + + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + raid: { + ...alerts.raid, + enabled: !!ev, + }, + }), + ); + }} + id="raid-enabled" + > + {alerts?.raid?.enabled && } + - - - - - - {t('pages.alerts.msg-info')} - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - raid: { ...alerts.raid, messages }, - }), - ); - }} - /> - - + + + + + + {t("pages.alerts.msg-info")} + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + raid: { ...alerts.raid, messages }, + }), + ); + }} + /> + + - - - - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - cheer: { - ...alerts.cheer, - enabled: !!ev, - }, - }), - ); - }} - id="raid-enabled" - > - {alerts?.cheer?.enabled && } - + + + + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + cheer: { + ...alerts.cheer, + enabled: !!ev, + }, + }), + ); + }} + id="raid-enabled" + > + {alerts?.cheer?.enabled && } + - - - - - - {t('pages.alerts.msg-info')} - { - void dispatch( - apiReducer.actions.twitchChatAlertsChanged({ - ...alerts, - cheer: { ...alerts.cheer, messages }, - }), - ); - }} - /> - - - - - -
    - ); + + +
    + + + {t("pages.alerts.msg-info")} + { + void dispatch( + apiReducer.actions.twitchChatAlertsChanged({ + ...alerts, + cheer: { ...alerts.cheer, messages }, + }), + ); + }} + /> + + + + + + + ); } diff --git a/frontend/src/ui/pages/twitch/ChatCommands.tsx b/frontend/src/ui/pages/twitch/ChatCommands.tsx index 6f99e0c..37fe161 100644 --- a/frontend/src/ui/pages/twitch/ChatCommands.tsx +++ b/frontend/src/ui/pages/twitch/ChatCommands.tsx @@ -1,475 +1,475 @@ -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 { 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, - 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'; + 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 ( - - - {name} - {item.description} - - {item.access_level !== 'everyone' && ( - - {t(`pages.botcommands.acl.${item.access_level}`)} - {item.access_level !== 'streamer' && '+'} - - )} - - - - - - - - (onDelete ? onDelete() : null)} - /> - - - - - - - {t(`pages.botcommands.response-types.${item.response_type}`)} - - {item.response} - - - ); + return ( + + + {name} + {item.description} + + {item.access_level !== "everyone" && ( + + {t(`pages.botcommands.acl.${item.access_level}`)} + {item.access_level !== "streamer" && "+"} + + )} + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + + + {t(`pages.botcommands.response-types.${item.response_type}`)} + + {item.response} + + + ); } -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(null); - const [accessLevel, setAccessLevel] = useState(item?.access_level ?? 'everyone'); - const [responseError, setResponseError] = useState(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(null); + const [accessLevel, setAccessLevel] = useState(item?.access_level ?? "everyone"); + const [responseError, setResponseError] = useState(null); + const { t } = useTranslation(); + const replyTypes: ReplyType[] = ["chat", "reply", "whisper", "announce"]; - return ( - -
    { - 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')); - } - })(); - }} - > - - - { - 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')} - /> - - - - setDescription(e.target.value)} - placeholder={t('pages.botcommands.command-desc-placeholder')} - /> - - - - - {responseError && ( - - {responseError} - - )} - - - - setAccessLevel(e.target.value as AccessLevelType)} - > - {accessLevels.map((level) => ( - - ))} - - {t('pages.botcommands.command-acl-help')} - - - - - - - -
    -
    - ); + return ( + +
    { + 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")); + } + })(); + }} + > + + + { + 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")} + /> + + + + setDescription(e.target.value)} + placeholder={t("pages.botcommands.command-desc-placeholder")} + /> + + + + + {responseError && ( + + {responseError} + + )} + + + + setAccessLevel(e.target.value as AccessLevelType)} + > + {accessLevels.map((level) => ( + + ))} + + {t("pages.botcommands.command-acl-help")} + + + + + + + +
    +
    + ); } export default function TwitchChatCommandsPage(): React.ReactElement { - const [commands, setCommands] = useModule(modules.twitchChatCommands); - const [filter, setFilter] = useState(''); - const [activeDialog, setActiveDialog] = useState(null); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const [commands, setCommands] = useModule(modules.twitchChatCommands); + const [filter, setFilter] = useState(""); + const [activeDialog, setActiveDialog] = useState(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 ( - - - {t('pages.botcommands.title')} - {t('pages.botcommands.desc')} - + return ( + + + {t("pages.botcommands.title")} + {t("pages.botcommands.desc")} + - - + + - setFilter(e.target.value)} - /> - - - {commands ? ( - Object.keys(commands ?? {}) - ?.filter( - (cmd) => - cmd.toLowerCase().includes(filterLC) || - commands[cmd].description.toLowerCase().includes(filterLC), - ) - .sort() - .map((cmd) => ( - toggleCommand(cmd)} - onEdit={() => - setActiveDialog({ - kind: 'edit', - name: cmd, - item: commands[cmd], - }) - } - onDelete={() => deleteCommand(cmd)} - /> - )) - ) : ( - {t('pages.botcommands.no-commands')} - )} - + setFilter(e.target.value)} + /> + + + {commands ? ( + Object.keys(commands ?? {}) + ?.filter( + (cmd) => + cmd.toLowerCase().includes(filterLC) || + commands[cmd].description.toLowerCase().includes(filterLC), + ) + .sort() + .map((cmd) => ( + toggleCommand(cmd)} + onEdit={() => + setActiveDialog({ + kind: "edit", + name: cmd, + item: commands[cmd], + }) + } + onDelete={() => deleteCommand(cmd)} + /> + )) + ) : ( + {t("pages.botcommands.no-commands")} + )} + - { - if (!open) { - // Reset dialog status on dialog close - setActiveDialog(null); - } - }} - > - {activeDialog && ( - setCommand(name, data)} /> - )} - - - ); + { + if (!open) { + // Reset dialog status on dialog close + setActiveDialog(null); + } + }} + > + {activeDialog && ( + setCommand(name, data)} /> + )} + + + ); } diff --git a/frontend/src/ui/pages/twitch/ChatTimers.tsx b/frontend/src/ui/pages/twitch/ChatTimers.tsx index 578fbab..db12c6d 100644 --- a/frontend/src/ui/pages/twitch/ChatTimers.tsx +++ b/frontend/src/ui/pages/twitch/ChatTimers.tsx @@ -1,412 +1,412 @@ -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 { 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(); + const { t } = useTranslation(); - return ( - - - {name} - - ( - {t('pages.bottimers.timer-parameters', { - time: humanTime(t, item.minimum_delay), - messages: item.minimum_chat_activity, - interval: humanTime(t, 300), - })} - ) - - - - - - - - - - (onDelete ? onDelete() : null)} - /> - - - - - {item.messages?.map((message, index) => ( - {message} - ))} - - ); + return ( + + + {name} + + ( + {t("pages.bottimers.timer-parameters", { + time: humanTime(t, item.minimum_delay), + messages: item.minimum_chat_activity, + interval: humanTime(t, 300), + })} + ) + + + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + {item.messages?.map((message, index) => ( + {message} + ))} + + ); } -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 ( - -
    { - if (!(e.target as HTMLFormElement).checkValidity()) { - return; - } - e.preventDefault(); - if (onSubmit) { - onSubmit(timerName, { - ...item, - messages, - minimum_delay: minDelay, - minimum_chat_activity: minActivity, - }); - } - }} - > - - - { - 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} - /> - - - - - - - - - - - { - const intNum = Number.parseInt(ev.target.value, 10); - if (Number.isNaN(intNum)) { - return; - } - setMinActivity(intNum); - }} - placeholder="#" - /> - {t('pages.bottimers.timer-activity-desc')} - - + return ( + + { + if (!(e.target as HTMLFormElement).checkValidity()) { + return; + } + e.preventDefault(); + if (onSubmit) { + onSubmit(timerName, { + ...item, + messages, + minimum_delay: minDelay, + minimum_chat_activity: minActivity, + }); + } + }} + > + + + { + 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} + /> + + + + + + + + + + + { + const intNum = Number.parseInt(ev.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + setMinActivity(intNum); + }} + placeholder="#" + /> + {t("pages.bottimers.timer-activity-desc")} + + - - - - + + + + - - - - - - - -
    - ); + + + + + + + +
    + ); } export default function TwitchChatTimersPage(): React.ReactElement { - const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers); - const [filter, setFilter] = useState(''); - const [activeDialog, setActiveDialog] = useState(null); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers); + const [filter, setFilter] = useState(""); + const [activeDialog, setActiveDialog] = useState(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 ( - - - {t('pages.bottimers.title')} - {t('pages.bottimers.desc')} - + return ( + + + {t("pages.bottimers.title")} + {t("pages.bottimers.desc")} + - - + + - setFilter(e.target.value)} - /> - - - {timerConfig?.timers ? ( - Object.keys(timerConfig?.timers ?? {}) - ?.filter((cmd) => cmd.toLowerCase().includes(filterLC)) - .sort() - .map((cmd) => ( - toggleTimer(cmd)} - onEdit={() => - setActiveDialog({ - kind: 'edit', - name: cmd, - item: timerConfig.timers[cmd], - }) - } - onDelete={() => deleteTimer(cmd)} - /> - )) - ) : ( - {t('pages.bottimers.no-timers')} - )} - + setFilter(e.target.value)} + /> + + + {timerConfig?.timers ? ( + Object.keys(timerConfig?.timers ?? {}) + ?.filter((cmd) => cmd.toLowerCase().includes(filterLC)) + .sort() + .map((cmd) => ( + toggleTimer(cmd)} + onEdit={() => + setActiveDialog({ + kind: "edit", + name: cmd, + item: timerConfig.timers[cmd], + }) + } + onDelete={() => deleteTimer(cmd)} + /> + )) + ) : ( + {t("pages.bottimers.no-timers")} + )} + - { - if (!open) { - // Reset dialog status on dialog close - setActiveDialog(null); - } - }} - > - {activeDialog && ( - setTimer(name, data)} /> - )} - - - ); + { + if (!open) { + // Reset dialog status on dialog close + setActiveDialog(null); + } + }} + > + {activeDialog && ( + setTimer(name, data)} /> + )} + + + ); } diff --git a/frontend/src/ui/pages/twitch/TwitchSettings/Page.tsx b/frontend/src/ui/pages/twitch/TwitchSettings/Page.tsx index e7fc8a8..dfd2682 100644 --- a/frontend/src/ui/pages/twitch/TwitchSettings/Page.tsx +++ b/frontend/src/ui/pages/twitch/TwitchSettings/Page.tsx @@ -1,79 +1,79 @@ -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 { 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 ( - - - {t('pages.twitch-settings.title')} - {t('pages.twitch-settings.subtitle')} - - - { - void dispatch( - setTwitchConfig({ - ...twitchConfig, - enabled: !!ev, - }), - ); - }} - id="enable" - > - {active && } - + return ( + + + {t("pages.twitch-settings.title")} + {t("pages.twitch-settings.subtitle")} + + + { + void dispatch( + setTwitchConfig({ + ...twitchConfig, + enabled: !!ev, + }), + ); + }} + id="enable" + > + {active && } + - - - - -
    - - - {t('pages.twitch-settings.api-configuration')} - {t('pages.twitch-settings.eventsub')} - {t('pages.twitch-settings.chat-settings')} - - - - - - - - - - - -
    -
    - ); + +
    +
    +
    +
    + + + {t("pages.twitch-settings.api-configuration")} + {t("pages.twitch-settings.eventsub")} + {t("pages.twitch-settings.chat-settings")} + + + + + + + + + + + +
    +
    + ); } diff --git a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchAPISettings.tsx b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchAPISettings.tsx index 2189786..aec979c 100644 --- a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchAPISettings.tsx +++ b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchAPISettings.tsx @@ -1,183 +1,183 @@ -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 { 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 = useTimedStatus(loadStatus.save); - const dispatch = useAppDispatch(); - const [revealClientSecret, setRevealClientSecret] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState({ - 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({ + 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 ( -
    { - void dispatch(setTwitchConfig(twitchConfig)); - ev.preventDefault(); - }} - > - {t('pages.twitch-settings.api-subheader')} - {t('pages.twitch-settings.apiguide-1')} - - - - {' '} - - https://dev.twitch.tv/console/apps/create - - - - - {t('pages.twitch-settings.apiguide-3')} + return ( + { + void dispatch(setTwitchConfig(twitchConfig)); + ev.preventDefault(); + }} + > + {t("pages.twitch-settings.api-subheader")} + {t("pages.twitch-settings.apiguide-1")} + + + + {" "} + + https://dev.twitch.tv/console/apps/create + + + + + {t("pages.twitch-settings.apiguide-3")} - 0 - ? httpConfig.bind - : `localhost${httpConfig?.bind ?? ':4337'}` - }/twitch/callback`, - [t('pages.twitch-settings.app-category')]: 'Broadcasting Suite', - }} - /> - - - - {'str1 '} - str2 - - - - - - - dispatch( - apiReducer.actions.twitchConfigChanged({ - ...twitchConfig, - api_client_id: ev.target.value, - }), - ) - } - /> - + 0 + ? httpConfig.bind + : `localhost${httpConfig?.bind ?? ":4337"}` + }/twitch/callback`, + [t("pages.twitch-settings.app-category")]: "Broadcasting Suite", + }} + /> + + + + {"str1 "} + str2 + + + + + + + dispatch( + apiReducer.actions.twitchConfigChanged({ + ...twitchConfig, + api_client_id: ev.target.value, + }), + ) + } + /> + - - - - dispatch( - apiReducer.actions.twitchConfigChanged({ - ...twitchConfig, - api_client_secret: ev.target.value, - }), - ) - } - /> - - - - - - { - setTestResult({ ...testResult, open: val }); - }} - > - { - setTestResult({ ...testResult, open: false }); - }} - /> - -
    - ); + + + + dispatch( + apiReducer.actions.twitchConfigChanged({ + ...twitchConfig, + api_client_secret: ev.target.value, + }), + ) + } + /> + + + + + + { + setTestResult({ ...testResult, open: val }); + }} + > + { + setTestResult({ ...testResult, open: false }); + }} + /> + + + ); } diff --git a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchChatSettings.tsx b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchChatSettings.tsx index 8db4a91..2af7810 100644 --- a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchChatSettings.tsx +++ b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchChatSettings.tsx @@ -1,79 +1,79 @@ -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'; +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 = useTimedStatus(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 ( -
    { - void dispatch(setChatConfig(chatConfig)); - ev.preventDefault(); - }} - > - {t('pages.twitch-settings.chat.chat-account')} - {t('pages.twitch-settings.chat.account-copy')} - - - {authKeyValue && ( - - )} - - + return ( + { + void dispatch(setChatConfig(chatConfig)); + ev.preventDefault(); + }} + > + {t("pages.twitch-settings.chat.chat-account")} + {t("pages.twitch-settings.chat.account-copy")} + + + {authKeyValue && ( + + )} + + - {t('pages.twitch-settings.chat.header')} - - - - dispatch( - apiReducer.actions.twitchChatConfigChanged({ - ...chatConfig, - command_cooldown: Number.parseInt(ev.target.value, 10), - }), - ) - } - /> - - - - ); + {t("pages.twitch-settings.chat.header")} + + + + dispatch( + apiReducer.actions.twitchChatConfigChanged({ + ...chatConfig, + command_cooldown: Number.parseInt(ev.target.value, 10), + }), + ) + } + /> + + + + ); } diff --git a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchEventSubSettings.tsx b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchEventSubSettings.tsx index 00b5222..f64e901 100644 --- a/frontend/src/ui/pages/twitch/TwitchSettings/TwitchEventSubSettings.tsx +++ b/frontend/src/ui/pages/twitch/TwitchSettings/TwitchEventSubSettings.tsx @@ -1,56 +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 ( - <> - {t('pages.twitch-settings.events.auth-message')} - - {t('pages.twitch-settings.events.current-status')} - - {t('pages.twitch-settings.events.sim-events')} - - {Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => ( - - ))} - - - ); + return ( + <> + {t("pages.twitch-settings.events.auth-message")} + + {t("pages.twitch-settings.events.current-status")} + + {t("pages.twitch-settings.events.sim-events")} + + {Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => ( + + ))} + + + ); } diff --git a/frontend/src/ui/theme/alert.ts b/frontend/src/ui/theme/alert.ts index bd3f6c1..91a45ce 100644 --- a/frontend/src/ui/theme/alert.ts +++ b/frontend/src/ui/theme/alert.ts @@ -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,113 +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" }, }); diff --git a/frontend/src/ui/theme/brand.ts b/frontend/src/ui/theme/brand.ts index 3d1fae8..09589f9 100644 --- a/frontend/src/ui/theme/brand.ts +++ b/frontend/src/ui/theme/brand.ts @@ -1,3 +1,3 @@ -export const APPNAME = 'strimertül'; +export const APPNAME = "strimertül"; export default { APPNAME }; diff --git a/frontend/src/ui/theme/dialog.ts b/frontend/src/ui/theme/dialog.ts index 6cef2a4..1b8e1e8 100644 --- a/frontend/src/ui/theme/dialog.ts +++ b/frontend/src/ui/theme/dialog.ts @@ -1,131 +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" }, }); diff --git a/frontend/src/ui/theme/forms.ts b/frontend/src/ui/theme/forms.ts index 393fbee..98e8ad4 100644 --- a/frontend/src/ui/theme/forms.ts +++ b/frontend/src/ui/theme/forms.ts @@ -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", }); diff --git a/frontend/src/ui/theme/index.ts b/frontend/src/ui/theme/index.ts index 3b55fa8..0239d72 100644 --- a/frontend/src/ui/theme/index.ts +++ b/frontend/src/ui/theme/index.ts @@ -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"; diff --git a/frontend/src/ui/theme/pages.ts b/frontend/src/ui/theme/pages.ts index cebcba1..529b520 100644 --- a/frontend/src/ui/theme/pages.ts +++ b/frontend/src/ui/theme/pages.ts @@ -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", }); diff --git a/frontend/src/ui/theme/table.ts b/frontend/src/ui/theme/table.ts index 3bdb281..6d01e6c 100644 --- a/frontend/src/ui/theme/table.ts +++ b/frontend/src/ui/theme/table.ts @@ -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", }); diff --git a/frontend/src/ui/theme/tabs.ts b/frontend/src/ui/theme/tabs.ts index 2fd1fc1..d4068e6 100644 --- a/frontend/src/ui/theme/tabs.ts +++ b/frontend/src/ui/theme/tabs.ts @@ -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", }); diff --git a/frontend/src/ui/theme/theme.ts b/frontend/src/ui/theme/theme.ts index 019d41b..5691c04 100644 --- a/frontend/src/ui/theme/theme.ts +++ b/frontend/src/ui/theme/theme.ts @@ -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; + } } diff --git a/frontend/src/ui/theme/toolbar.ts b/frontend/src/ui/theme/toolbar.ts index 3e534a4..aced944 100644 --- a/frontend/src/ui/theme/toolbar.ts +++ b/frontend/src/ui/theme/toolbar.ts @@ -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", + }, }); diff --git a/frontend/src/ui/theme/utils.ts b/frontend/src/ui/theme/utils.ts index b90f029..9330b3e 100644 --- a/frontend/src/ui/theme/utils.ts +++ b/frontend/src/ui/theme/utils.ts @@ -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)", }); diff --git a/frontend/src/vendor/vlq/decode.ts b/frontend/src/vendor/vlq/decode.ts index 3d0b6e3..efc059f 100644 --- a/frontend/src/vendor/vlq/decode.ts +++ b/frontend/src/vendor/vlq/decode.ts @@ -12,45 +12,45 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI const chatToInt: Record = {}; -'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[] = []; + 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]; } -- 2.45.2