This commit is contained in:
2025-03-02 17:15:58 +00:00
commit e4253e6de2
26 changed files with 1806 additions and 0 deletions

5
.env.development Normal file
View File

@@ -0,0 +1,5 @@
# Generate a client id through the twitch CLI
VITE_CLIENT_ID=412c7e3c3df177bbd466a213ce4d05
VITE_TWITCH_API_BASE=http://localhost:8080/mock
# Then get yourself a token via curl -X POST 'http://localhost:8080/auth/authorize?client_id={client_id}&client_secret={client_secret}&grant_type=user_token&user_id={user_id}&scope={scopes}
VITE_TOKEN_OVERRIDE=ed70cddba8db975

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_CLIENT_ID=vzn24kqo5sq0g9duja3vpuenknend6
VITE_TWITCH_API_BASE=https://api.twitch.tv/helix

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.direnv

26
biome.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"all": true,
"correctness": {
"noUndeclaredDependencies": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"style": {
"useExplicitLengthCheck": "off",
"useNamingConvention": "off"
}
},
"ignore": [
"src/Services/Miniflux/Requests/*",
"src/Services/Miniflux/Responses/*"
]
}
}

64
flake.lock generated Normal file
View File

@@ -0,0 +1,64 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": [
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1736200483,
"narHash": "sha256-JO+lFN2HsCwSLMUWXHeOad6QUxOuwe9UOAF/iSl1J4I=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3f0a8ac25fb674611b98089ca3a5dd6480175751",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1680978846,
"narHash": "sha256-Gtqg8b/v49BFDpDetjclCYXm8mAnTrUzR0JnE2nv5aw=",
"owner": "nix-systems",
"repo": "x86_64-linux",
"rev": "2ecfcac5e15790ba6ce360ceccddb15ad16d08a8",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "x86_64-linux",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

26
flake.nix Normal file
View File

@@ -0,0 +1,26 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
inputs.systems.url = "github:nix-systems/x86_64-linux";
inputs.flake-utils = {
url = "github:numtide/flake-utils";
inputs.systems.follows = "systems";
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs;[
biome
nodejs_22
twitch-cli
];
};
}
);
}

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#000000">
<title>Twitch Chatter Helper</title>
<style>
body:has(dialog[open]) {
overflow: hidden;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

1023
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "vite-template-solid",
"version": "0.0.0",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vite-plugin-solid": "^2.11.6",
"vite-tsconfig-paths": "^5.1.4"
},
"dependencies": {
"@picocss/pico": "^2.0.6",
"es-toolkit": "^1.32.0",
"solid-js": "^1.9.5"
}
}

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Chatters } from "@components/Chatters.tsx";
import { Contexts } from "@contexts/Contexts.tsx";
import type { Component } from "solid-js";
export const App: Component = () => {
return (
<main class="container">
<h1>Current Chatters</h1>
<Contexts>
<Chatters />
</Contexts>
</main>
);
};

214
src/components/Chatters.tsx Normal file
View File

@@ -0,0 +1,214 @@
import { TwitchServiceContext } from "@contexts/TwitchServiceContext";
import { UserContext } from "@contexts/UserContext";
import type { ChatterResponse } from "@services/twitch/responses/GetChattersResponse.ts";
import type { SubscriptionResponse } from "@services/twitch/responses/GetSubscriptionsResponse.ts";
import { useRequiredContext } from "@utils/useRequiredContext";
import { sortBy } from "es-toolkit";
import {
type Accessor,
type Component,
For,
type Setter,
Show,
createResource,
createSignal,
} from "solid-js";
import { ResourceComponent } from "./ResourceComponent.tsx";
const ExportDialog: Component<{
setRef: Setter<HTMLDialogElement | undefined>;
ref: Accessor<HTMLDialogElement | undefined>;
getJson: () => string;
}> = (props) => {
const [clipboardStatus, setClipboardStatus] = createSignal<
"Writing" | "Written" | "Initial"
>("Initial");
const copyClipboard = async () => {
setClipboardStatus("Writing");
await navigator.clipboard.writeText(props.getJson());
setClipboardStatus("Written");
setTimeout(() => setClipboardStatus("Initial"), 1000);
};
return (
<dialog ref={props.setRef}>
<article style={{ display: "grid", gap: "1rem" }}>
<div style={{ display: "flex" }}>
<button
type="button"
onClick={copyClipboard}
disabled={clipboardStatus() !== "Initial"}
>
<Show
when={clipboardStatus() === "Written"}
fallback={"Copy to Clipboard"}
>
Copied!
</Show>
</button>
<button
type="button"
onClick={() => props.ref()?.close()}
style={{ "margin-left": "auto" }}
>
Close
</button>
</div>
<pre
style={{ "max-height": "80vh", overflow: "auto", padding: "1rem" }}
>
{props.getJson()}
</pre>
</article>
</dialog>
);
};
const ChatttersView: Component<{
chatters: ChatterResponse[];
subscriptions: SubscriptionResponse[];
}> = (props) => {
const [selected, setSelected] = createSignal<string[]>([]);
const [search, setSearch] = createSignal<string>("");
const subs = sortBy(props.subscriptions, ["user_name"]);
const scrubs = sortBy(
props.chatters.filter((chat) =>
props.subscriptions.find((sub) => sub.user_id !== chat.user_id),
),
["user_name"],
);
const onChange = (id: string) => {
setSelected((prev: string[]) =>
prev.includes(id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const oneHundredClick = () => {
setSelected([
...subs.slice(0, 100).map((sub) => sub.user_id),
...scrubs.slice(0, 100 - subs.length).map((scrub) => scrub.user_id),
]);
};
const [dialogRef, setDialogRef] = createSignal<HTMLDialogElement>();
const getJson = () =>
JSON.stringify(
props.chatters
.filter((chat) => selected().includes(chat.user_id))
.map((chat) => chat.user_name),
null,
4,
);
return (
<>
<ExportDialog ref={dialogRef} setRef={setDialogRef} getJson={getJson} />
<div style={{ display: "grid", gap: "1rem" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<button
type="button"
onClick={() =>
setSelected(props.chatters.map((chat) => chat.user_id))
}
>
Select All
</button>
<button type="button" onClick={oneHundredClick}>
Select First 100
</button>
<button type="button" onClick={() => setSelected([])}>
Select None
</button>
<button
type="button"
onClick={() => dialogRef()?.showModal()}
style={{ "margin-left": "auto" }}
>
Export to JSON
</button>
</div>
<input
placeholder="Search"
onInput={(e) => setSearch(e.currentTarget.value.toLocaleLowerCase())}
/>
<article>
<h2>Subs</h2>
<For
each={subs.filter((sub) =>
search()
? sub.user_name.toLocaleLowerCase().includes(search())
: true,
)}
>
{(sub) => (
<label>
<input
type="checkbox"
onChange={() => onChange(sub.user_id)}
checked={selected().includes(sub.user_id)}
/>
{sub.user_name}
</label>
)}
</For>
</article>
<article>
<h2>Scrubs</h2>
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "1rem" }}>
<For
each={scrubs.filter((scrub) =>
search()
? scrub.user_name.toLocaleLowerCase().includes(search())
: true,
)}
>
{(scrub) => (
<label>
<input
type="checkbox"
onChange={() => onChange(scrub.user_id)}
checked={selected().includes(scrub.user_id)}
/>
{scrub.user_name}
</label>
)}
</For>
</div>
</article>
</div>
</>
);
};
export const Chatters: Component = () => {
const twitchService = useRequiredContext(TwitchServiceContext);
const user = useRequiredContext(UserContext);
const [resource] = createResource(async () => {
const chatters = await twitchService.getChatters(user.id);
const subscriptions = await twitchService.getSubscribers(
user.id,
chatters.map((e) => e.user_id),
);
return {
chatters,
subscriptions,
};
});
return (
<ResourceComponent resource={resource}>
{(response) => (
<ChatttersView
chatters={response().chatters}
subscriptions={response().subscriptions}
/>
)}
</ResourceComponent>
);
};

View File

@@ -0,0 +1,5 @@
import type { Component } from "solid-js";
export const Loading: Component = () => {
return "Loading...";
};

View File

@@ -0,0 +1,68 @@
import { Loading } from "@components/Loading.tsx";
import { parseError } from "@utils/parseError.ts";
import {
type Accessor,
type Component,
type JSX,
Match,
type Resource,
Show,
Switch,
} from "solid-js";
export const ResourceComponent = <TValue,>(props: {
children: (value: Accessor<NonNullable<TValue>>) => JSX.Element;
resource: Resource<TValue>;
errorComponent?: (e: Accessor<Error>) => JSX.Element;
loadingComponent?: () => JSX.Element;
fallback?: JSX.Element;
}) => {
const isError = () => {
const error = props.resource.error;
if (error) {
return parseError(error);
}
return undefined;
};
return (
<Switch fallback={props.fallback}>
<Match when={props.resource.loading}>
<Show when={props.loadingComponent} fallback={<Loading />}>
{(loadingComponent) => loadingComponent()()}
</Show>
</Match>
<Match when={isError()}>
{(error) => (
<Show
when={props.errorComponent}
fallback={<FallbackError error={error} />}
>
{(errorComponent) => errorComponent()(error)}
</Show>
)}
</Match>
<Match when={props.resource()}>{(value) => props.children(value)}</Match>
</Switch>
);
};
const FallbackError: Component<{ error: Accessor<unknown> }> = (props) => {
return (
<div role="alert" class="alert alert-error">
<div>
An error occurred
<details>
<summary>Click for details</summary>
<p>
{JSON.stringify(
props.error(),
Object.getOwnPropertyNames(props.error()),
2,
)}
</p>
</details>
</div>
</div>
);
};

11
src/contexts/Contexts.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { ParentComponent } from "solid-js";
import { TwitchServiceContextProvider } from "./TwitchServiceContext.tsx";
import { UserContextProvider } from "./UserContext.tsx";
export const Contexts: ParentComponent = (props) => {
return (
<TwitchServiceContextProvider>
<UserContextProvider>{props.children}</UserContextProvider>
</TwitchServiceContextProvider>
);
};

View File

@@ -0,0 +1,56 @@
import { TwitchApiService } from "@services/twitch/TwitchApiService.ts";
import {
type ParentComponent,
Show,
createContext,
createRenderEffect,
createSignal,
} from "solid-js";
const searchParams = new URLSearchParams({
client_id: import.meta.env["VITE_CLIENT_ID"],
redirect_uri: new URL(location.origin).toString(),
response_type: "token",
scope: "moderator:read:chatters channel:read:subscriptions",
});
const url = new URL(
`https://id.twitch.tv/oauth2/authorize?${searchParams.toString()}`,
);
export const TwitchServiceContext = createContext<TwitchApiService>();
export const TwitchServiceContextProvider: ParentComponent = (props) => {
const [twitchApiService, setTwitchApiService] =
createSignal<TwitchApiService>();
createRenderEffect(() => {
const devOverride = import.meta.env["VITE_TOKEN_OVERRIDE"];
if (devOverride) {
setTwitchApiService(new TwitchApiService(devOverride));
return;
}
const urlSearchParams = new URLSearchParams(location.hash.slice(1));
const accessToken = urlSearchParams.get("access_token");
if (accessToken) {
setTwitchApiService(new TwitchApiService(accessToken));
window.location.hash = "";
}
}, []);
return (
<Show
when={twitchApiService()}
fallback={
<p>
To use this application, please{" "}
<a href={url.toString()}>Connect with Twitch</a>
</p>
}
>
<TwitchServiceContext.Provider value={twitchApiService()}>
{props.children}
</TwitchServiceContext.Provider>
</Show>
);
};

View File

@@ -0,0 +1,23 @@
import { ResourceComponent } from "@components/ResourceComponent.tsx";
import type { UserResponse } from "@services/twitch/responses/GetUsersResponse.ts";
import { type ParentComponent, createContext, createResource } from "solid-js";
import { useRequiredContext } from "../utils/useRequiredContext.ts";
import { TwitchServiceContext } from "./TwitchServiceContext.tsx";
export const UserContext = createContext<UserResponse>();
export const UserContextProvider: ParentComponent = (props) => {
const twitchService = useRequiredContext(TwitchServiceContext);
const [resource] = createResource(async () => await twitchService.getUser());
return (
<ResourceComponent resource={resource}>
{(user) => (
<UserContext.Provider value={user()}>
{props.children}
</UserContext.Provider>
)}
</ResourceComponent>
);
};

8
src/index.tsx Normal file
View File

@@ -0,0 +1,8 @@
/* @refresh reload */
import "@picocss/pico";
import { render } from "solid-js/web";
import { App } from "./App.tsx";
const root = document.createElement("div");
document.body.appendChild(root);
render(() => <App />, root);

View File

@@ -0,0 +1,89 @@
import type {
ChatterResponse,
GetChattersResponse,
} from "./responses/GetChattersResponse.ts";
import type {
GetSubscriptionsResponse,
SubscriptionResponse,
} from "./responses/GetSubscriptionsResponse.ts";
import type {
GetUsersResponse,
UserResponse,
} from "./responses/GetUsersResponse.ts";
export class TwitchApiService {
baseUrl: string;
headers: HeadersInit;
constructor(accessToken: string) {
this.baseUrl = import.meta.env["VITE_TWITCH_API_BASE"];
this.headers = {
Authorization: `Bearer ${accessToken}`,
"Client-Id": import.meta.env["VITE_CLIENT_ID"],
};
}
private async parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(
`${response.status.toString()}: ${await response.text()}`,
);
}
return await response.json();
}
getUser = async (): Promise<UserResponse> => {
const url = new URL([this.baseUrl, "users"].join("/"));
const response = await fetch(url.toString(), { headers: this.headers });
const users = await this.parseResponse<GetUsersResponse>(response);
return users.data[0] as UserResponse;
};
getChatters = async (id: string): Promise<ChatterResponse[]> => {
const params = new URLSearchParams({
broadcaster_id: id,
moderator_id: id,
first: (1000).toString(),
});
const url = new URL([this.baseUrl, "chat", "chatters"].join("/"));
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
let parsed = await this.parseResponse<GetChattersResponse>(response);
let chatters = parsed.data;
while (parsed.pagination?.cursor) {
params.set("after", parsed.pagination.cursor);
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
parsed = await this.parseResponse<GetChattersResponse>(response);
chatters = chatters.concat(parsed.data);
}
return chatters;
};
getSubscribers = async (
id: string,
userIds: string[],
): Promise<SubscriptionResponse[]> => {
const url = new URL([this.baseUrl, "subscriptions"].join("/"));
let subscribers: SubscriptionResponse[] = [];
for (let index = 0; index < userIds.length; index += 100) {
const ids = userIds
.slice(index, index + 100)
.map((id) => ["user_id", id]);
const params = new URLSearchParams([["broadcaster_id", id]].concat(ids));
const response = await fetch(`${url.toString()}?${params.toString()}`, {
headers: this.headers,
});
const parsed =
await this.parseResponse<GetSubscriptionsResponse>(response);
subscribers = subscribers.concat(parsed.data);
}
return subscribers;
};
}

View File

@@ -0,0 +1,13 @@
export interface ChatterResponse {
user_id: string;
user_login: string;
user_name: string;
}
export interface GetChattersResponse {
data: ChatterResponse[];
pagination?: {
cursor: string;
};
total: number;
}

View File

@@ -0,0 +1,23 @@
export interface SubscriptionResponse {
broadcaster_id: string;
broadcaster_login: string;
broadcaster_name: string;
gifter_id: string;
gifter_login: string;
gifter_name: string;
is_gift: boolean;
tier: string;
plan_name: string;
user_id: string;
user_name: string;
user_login: string;
}
export interface GetSubscriptionsResponse {
data: SubscriptionResponse[];
pagination: {
cursor: string;
};
total: number;
points: number;
}

View File

@@ -0,0 +1,17 @@
export interface UserResponse {
id: string;
login: string;
display_name: string;
type: string;
broadcaster_type: string;
description: string;
profile_image_url: string;
offline_image_url: string;
view_count: number;
email: string;
created_at: string;
}
export interface GetUsersResponse {
data: UserResponse[];
}

3
src/utils/parseError.ts Normal file
View File

@@ -0,0 +1,3 @@
export function parseError(error: unknown): Error {
return error instanceof Error ? error : new Error(JSON.stringify(error));
}

View File

@@ -0,0 +1,13 @@
import { type Context, useContext } from "solid-js";
export const useRequiredContext = <T>(context: Context<T | undefined>): T => {
const getContext = useContext(context);
if (getContext === undefined) {
throw new Error(
`${context.constructor.name} must be used within a provider instance`,
);
}
return getContext;
};

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"isolatedModules": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"allowImportingTsExtensions": true,
"paths": {
"@components/*": ["./src/components/*"],
"@services/*": ["./src/services/*"],
"@contexts/*": ["./src/contexts/*"],
"@utils/*": ["./src/utils/*"],
"@src/*": ["./src/*"]
}
},
"include": ["src/**/*"]
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths(), solidPlugin()],
server: {
port: 3000,
},
build: {
target: "esnext",
watch: {
exclude: ["**/.devenv/**", "**/.direnv/**"],
},
},
});