init
This commit is contained in:
5
.env.development
Normal file
5
.env.development
Normal 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
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_CLIENT_ID=vzn24kqo5sq0g9duja3vpuenknend6
|
||||||
|
VITE_TWITCH_API_BASE=https://api.twitch.tv/helix
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.direnv
|
||||||
26
biome.json
Normal file
26
biome.json
Normal 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
64
flake.lock
generated
Normal 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
26
flake.nix
Normal 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
18
index.html
Normal 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
1023
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
14
src/App.tsx
Normal 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
214
src/components/Chatters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
src/components/Loading.tsx
Normal file
5
src/components/Loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
export const Loading: Component = () => {
|
||||||
|
return "Loading...";
|
||||||
|
};
|
||||||
68
src/components/ResourceComponent.tsx
Normal file
68
src/components/ResourceComponent.tsx
Normal 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
11
src/contexts/Contexts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
src/contexts/TwitchServiceContext.tsx
Normal file
56
src/contexts/TwitchServiceContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
src/contexts/UserContext.tsx
Normal file
23
src/contexts/UserContext.tsx
Normal 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
8
src/index.tsx
Normal 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);
|
||||||
89
src/services/twitch/TwitchApiService.ts
Normal file
89
src/services/twitch/TwitchApiService.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/services/twitch/responses/GetChattersResponse.ts
Normal file
13
src/services/twitch/responses/GetChattersResponse.ts
Normal 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;
|
||||||
|
}
|
||||||
23
src/services/twitch/responses/GetSubscriptionsResponse.ts
Normal file
23
src/services/twitch/responses/GetSubscriptionsResponse.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/services/twitch/responses/GetUsersResponse.ts
Normal file
17
src/services/twitch/responses/GetUsersResponse.ts
Normal 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
3
src/utils/parseError.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function parseError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(JSON.stringify(error));
|
||||||
|
}
|
||||||
13
src/utils/useRequiredContext.ts
Normal file
13
src/utils/useRequiredContext.ts
Normal 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
41
tsconfig.json
Normal 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
16
vite.config.ts
Normal 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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user