feat: managing admins in webui
This commit is contained in:
parent
f7b29dd150
commit
a35f07b036
10
README.md
10
README.md
|
@ -13,17 +13,17 @@ You can put these in `.env` file or pass them as environment variables.
|
||||||
|
|
||||||
- `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather).
|
- `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather).
|
||||||
Required.
|
Required.
|
||||||
- `TG_ADMIN_USERNAMES` - Comma separated list of usernames of users that can use admin commands.
|
|
||||||
- `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A
|
- `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A
|
||||||
temporary file is used by default.
|
temporary file is used by default.
|
||||||
- `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`.
|
- `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`.
|
||||||
|
|
||||||
You can configure more stuff in [Eris WebUI](http://localhost:5999/) when running.
|
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
- Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory)
|
1. Start Eris: `deno task start`
|
||||||
- Start Eris: `deno task start`
|
2. Visit [Eris WebUI](http://localhost:5999/) and login via Telegram.
|
||||||
|
3. Promote yourself to admin in the Eris WebUI.
|
||||||
|
4. Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory)
|
||||||
|
5. Add a new worker in the Eris WebUI.
|
||||||
|
|
||||||
## Codegen
|
## Codegen
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Model } from "indexed_kv";
|
||||||
|
import { info } from "std/log/mod.ts";
|
||||||
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
|
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
|
||||||
|
import { getUser, withAdmin, withUser } from "./withUser.ts";
|
||||||
|
|
||||||
|
export type AdminData = Admin & { id: string };
|
||||||
|
|
||||||
|
function getAdminData(adminEntry: Model<Admin>): AdminData {
|
||||||
|
return { id: adminEntry.id, ...adminEntry.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminsRoute = createPathFilter({
|
||||||
|
"": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
const adminEntries = await adminStore.getAll();
|
||||||
|
const admins = adminEntries.map(getAdminData);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: admins satisfies AdminData[] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
POST: createEndpoint(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tgUserId: adminSchema.properties.tgUserId,
|
||||||
|
},
|
||||||
|
required: ["tgUserId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ query, body }) => {
|
||||||
|
return withAdmin(query, async (user, adminEntry) => {
|
||||||
|
const newAdminUser = await getUser(body.data.tgUserId);
|
||||||
|
const newAdminEntry = await adminStore.create({
|
||||||
|
tgUserId: body.data.tgUserId,
|
||||||
|
promotedBy: adminEntry.id,
|
||||||
|
});
|
||||||
|
info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(newAdminEntry) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
"promote_self": createMethodFilter({
|
||||||
|
POST: createEndpoint(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// if there are no admins, allow any user to promote themselves
|
||||||
|
async ({ query }) => {
|
||||||
|
return withUser(query, async (user) => {
|
||||||
|
const adminEntries = await adminStore.getAll();
|
||||||
|
if (adminEntries.length === 0) {
|
||||||
|
const newAdminEntry = await adminStore.create({
|
||||||
|
tgUserId: user.id,
|
||||||
|
promotedBy: null,
|
||||||
|
});
|
||||||
|
info(`User ${user.first_name} promoted themselves to admin`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(newAdminEntry) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { type: "text/plain", data: `You are not allowed to promote yourself` },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
"{adminId}": createPathFilter({
|
||||||
|
"": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{},
|
||||||
|
async ({ params }) => {
|
||||||
|
const adminEntry = await adminStore.getById(params.adminId!);
|
||||||
|
if (!adminEntry) {
|
||||||
|
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: getAdminData(adminEntry) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DELETE: createEndpoint(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
sessionId: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ params, query }) => {
|
||||||
|
const deletedAdminEntry = await adminStore.getById(params.adminId!);
|
||||||
|
if (!deletedAdminEntry) {
|
||||||
|
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
|
||||||
|
}
|
||||||
|
const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId);
|
||||||
|
return withUser(query, async (chat) => {
|
||||||
|
await deletedAdminEntry.delete();
|
||||||
|
info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { type: "application/json", data: null },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
|
@ -2,7 +2,7 @@ import { deepMerge } from "std/collections/deep_merge.ts";
|
||||||
import { info } from "std/log/mod.ts";
|
import { info } from "std/log/mod.ts";
|
||||||
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||||
import { configSchema, getConfig, setConfig } from "../app/config.ts";
|
import { configSchema, getConfig, setConfig } from "../app/config.ts";
|
||||||
import { withUser } from "./withUser.ts";
|
import { withAdmin } from "./withUser.ts";
|
||||||
|
|
||||||
export const paramsRoute = createMethodFilter({
|
export const paramsRoute = createMethodFilter({
|
||||||
GET: createEndpoint(
|
GET: createEndpoint(
|
||||||
|
@ -22,13 +22,13 @@ export const paramsRoute = createMethodFilter({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ query, body }) => {
|
async ({ query, body }) => {
|
||||||
return withUser(query, async (chat) => {
|
return withAdmin(query, async (user) => {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
info(`User ${chat.username} updated default params: ${JSON.stringify(body.data)}`);
|
info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`);
|
||||||
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
|
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
|
||||||
await setConfig({ defaultParams });
|
await setConfig({ defaultParams });
|
||||||
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||||
}, { admin: true });
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { createLoggerMiddleware, createPathFilter } from "t_rest/server";
|
import { createLoggerMiddleware, createPathFilter } from "t_rest/server";
|
||||||
|
import { adminsRoute } from "./adminsRoute.ts";
|
||||||
import { botRoute } from "./botRoute.ts";
|
import { botRoute } from "./botRoute.ts";
|
||||||
import { jobsRoute } from "./jobsRoute.ts";
|
import { jobsRoute } from "./jobsRoute.ts";
|
||||||
import { paramsRoute } from "./paramsRoute.ts";
|
import { paramsRoute } from "./paramsRoute.ts";
|
||||||
|
@ -9,13 +10,14 @@ import { workersRoute } from "./workersRoute.ts";
|
||||||
|
|
||||||
export const serveApi = createLoggerMiddleware(
|
export const serveApi = createLoggerMiddleware(
|
||||||
createPathFilter({
|
createPathFilter({
|
||||||
|
"admins": adminsRoute,
|
||||||
|
"bot": botRoute,
|
||||||
"jobs": jobsRoute,
|
"jobs": jobsRoute,
|
||||||
"sessions": sessionsRoute,
|
"sessions": sessionsRoute,
|
||||||
"users": usersRoute,
|
|
||||||
"settings/params": paramsRoute,
|
"settings/params": paramsRoute,
|
||||||
"stats": statsRoute,
|
"stats": statsRoute,
|
||||||
|
"users": usersRoute,
|
||||||
"workers": workersRoute,
|
"workers": workersRoute,
|
||||||
"bot": botRoute,
|
|
||||||
}),
|
}),
|
||||||
{ filterStatus: (status) => status >= 400 },
|
{ filterStatus: (status) => status >= 400 },
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { getConfig } from "../app/config.ts";
|
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
|
import { getUser } from "./withUser.ts";
|
||||||
|
import { adminStore } from "../app/adminStore.ts";
|
||||||
|
|
||||||
export const usersRoute = createPathFilter({
|
export const usersRoute = createPathFilter({
|
||||||
"{userId}/photo": createMethodFilter({
|
"{userId}/photo": createMethodFilter({
|
||||||
GET: createEndpoint(
|
GET: createEndpoint(
|
||||||
{ query: null, body: null },
|
{ query: null, body: null },
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const chat = await bot.api.getChat(params.userId!);
|
const user = await getUser(Number(params.userId!));
|
||||||
if (chat.type !== "private") {
|
const photoData = user.photo?.small_file_id
|
||||||
throw new Error("Chat is not private");
|
|
||||||
}
|
|
||||||
const photoData = chat.photo?.small_file_id
|
|
||||||
? await fetch(
|
? await fetch(
|
||||||
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
||||||
chat.photo.small_file_id,
|
user.photo.small_file_id,
|
||||||
).then((file) => file.file_path)}`,
|
).then((file) => file.file_path)}`,
|
||||||
).then((resp) => resp.arrayBuffer())
|
).then((resp) => resp.arrayBuffer())
|
||||||
: undefined;
|
: undefined;
|
||||||
|
@ -36,17 +34,14 @@ export const usersRoute = createPathFilter({
|
||||||
GET: createEndpoint(
|
GET: createEndpoint(
|
||||||
{ query: null, body: null },
|
{ query: null, body: null },
|
||||||
async ({ params }) => {
|
async ({ params }) => {
|
||||||
const chat = await bot.api.getChat(params.userId!);
|
const user = await getUser(Number(params.userId!));
|
||||||
if (chat.type !== "private") {
|
const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id });
|
||||||
throw new Error("Chat is not private");
|
const admin = adminEntry?.value;
|
||||||
}
|
|
||||||
const config = await getConfig();
|
|
||||||
const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username);
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
data: { ...chat, isAdmin },
|
data: { ...user, admin },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,28 +1,40 @@
|
||||||
import { Chat } from "grammy_types";
|
import { Chat } from "grammy_types";
|
||||||
|
import { Model } from "indexed_kv";
|
||||||
import { Output } from "t_rest/client";
|
import { Output } from "t_rest/client";
|
||||||
import { getConfig } from "../app/config.ts";
|
import { Admin, adminStore } from "../app/adminStore.ts";
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
import { sessions } from "./sessionsRoute.ts";
|
import { sessions } from "./sessionsRoute.ts";
|
||||||
|
|
||||||
export async function withUser<O extends Output>(
|
export async function withUser<O extends Output>(
|
||||||
query: { sessionId: string },
|
query: { sessionId: string },
|
||||||
cb: (user: Chat.PrivateGetChat) => Promise<O>,
|
cb: (user: Chat.PrivateGetChat) => Promise<O>,
|
||||||
options?: { admin?: boolean },
|
|
||||||
) {
|
) {
|
||||||
const session = sessions.get(query.sessionId);
|
const session = sessions.get(query.sessionId);
|
||||||
if (!session?.userId) {
|
if (!session?.userId) {
|
||||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
|
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
|
||||||
}
|
}
|
||||||
const chat = await bot.api.getChat(session.userId);
|
const user = await getUser(session.userId);
|
||||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
return cb(user);
|
||||||
if (options?.admin) {
|
|
||||||
if (!chat.username) {
|
|
||||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } } as const;
|
|
||||||
}
|
}
|
||||||
const config = await getConfig();
|
|
||||||
if (!config?.adminUsernames?.includes(chat.username)) {
|
export async function withAdmin<O extends Output>(
|
||||||
|
query: { sessionId: string },
|
||||||
|
cb: (user: Chat.PrivateGetChat, admin: Model<Admin>) => Promise<O>,
|
||||||
|
) {
|
||||||
|
const session = sessions.get(query.sessionId);
|
||||||
|
if (!session?.userId) {
|
||||||
|
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
|
||||||
|
}
|
||||||
|
const user = await getUser(session.userId);
|
||||||
|
const [admin] = await adminStore.getBy("tgUserId", { value: session.userId });
|
||||||
|
if (!admin) {
|
||||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const;
|
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const;
|
||||||
}
|
}
|
||||||
|
return cb(user, admin);
|
||||||
}
|
}
|
||||||
return cb(chat);
|
|
||||||
|
export async function getUser(userId: number): Promise<Chat.PrivateGetChat> {
|
||||||
|
const chat = await bot.api.getChat(userId);
|
||||||
|
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||||
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
} from "../app/workerInstanceStore.ts";
|
} from "../app/workerInstanceStore.ts";
|
||||||
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
||||||
import { omitUndef } from "../utils/omitUndef.ts";
|
import { omitUndef } from "../utils/omitUndef.ts";
|
||||||
import { withUser } from "./withUser.ts";
|
import { withAdmin } from "./withUser.ts";
|
||||||
|
|
||||||
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
|
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -89,14 +89,14 @@ export const workersRoute = createPathFilter({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ query, body }) => {
|
async ({ query, body }) => {
|
||||||
return withUser(query, async (chat) => {
|
return withAdmin(query, async (user) => {
|
||||||
const workerInstance = await workerInstanceStore.create(body.data);
|
const workerInstance = await workerInstanceStore.create(body.data);
|
||||||
info(`User ${chat.username} created worker ${workerInstance.id}`);
|
info(`User ${user.first_name} created worker ${workerInstance.value.name}`);
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
||||||
};
|
};
|
||||||
}, { admin: true });
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
@ -131,9 +131,9 @@ export const workersRoute = createPathFilter({
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
return withUser(query, async (chat) => {
|
return withAdmin(query, async (user) => {
|
||||||
info(
|
info(
|
||||||
`User ${chat.username} updated worker ${params.workerId}: ${
|
`User ${user.first_name} updated worker ${workerInstance.value.name}: ${
|
||||||
JSON.stringify(body.data)
|
JSON.stringify(body.data)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
@ -142,7 +142,7 @@ export const workersRoute = createPathFilter({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
body: { type: "application/json", data: await getWorkerData(workerInstance) },
|
||||||
};
|
};
|
||||||
}, { admin: true });
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DELETE: createEndpoint(
|
DELETE: createEndpoint(
|
||||||
|
@ -157,11 +157,11 @@ export const workersRoute = createPathFilter({
|
||||||
if (!workerInstance) {
|
if (!workerInstance) {
|
||||||
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
|
||||||
}
|
}
|
||||||
return withUser(query, async (chat) => {
|
return withAdmin(query, async (user) => {
|
||||||
info(`User ${chat.username} deleted worker ${params.workerId}`);
|
info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`);
|
||||||
await workerInstance.delete();
|
await workerInstance.delete();
|
||||||
return { status: 200, body: { type: "application/json", data: null } };
|
return { status: 200, body: { type: "application/json", data: null } };
|
||||||
}, { admin: true });
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Store } from "indexed_kv";
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
|
import { db } from "./db.ts";
|
||||||
|
|
||||||
|
export const adminSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tgUserId: { type: "number" },
|
||||||
|
promotedBy: { type: ["string", "null"] },
|
||||||
|
},
|
||||||
|
required: ["tgUserId", "promotedBy"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
|
export type Admin = jsonType<typeof adminSchema>;
|
||||||
|
|
||||||
|
type AdminIndices = {
|
||||||
|
tgUserId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminStore = new Store<Admin, AdminIndices>(db, "adminUsers", {
|
||||||
|
indices: {
|
||||||
|
tgUserId: { getValue: (adminUser) => adminUser.tgUserId },
|
||||||
|
},
|
||||||
|
});
|
|
@ -4,7 +4,6 @@ import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
export const configSchema = {
|
export const configSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
adminUsernames: { type: "array", items: { type: "string" } },
|
|
||||||
pausedReason: { type: ["string", "null"] },
|
pausedReason: { type: ["string", "null"] },
|
||||||
maxUserJobs: { type: "number" },
|
maxUserJobs: { type: "number" },
|
||||||
maxJobs: { type: "number" },
|
maxJobs: { type: "number" },
|
||||||
|
@ -31,7 +30,6 @@ export async function getConfig(): Promise<Config> {
|
||||||
const configEntry = await db.get<Config>(["config"]);
|
const configEntry = await db.get<Config>(["config"]);
|
||||||
const config = configEntry?.value;
|
const config = configEntry?.value;
|
||||||
return {
|
return {
|
||||||
adminUsernames: config?.adminUsernames ?? Deno.env.get("TG_ADMIN_USERNAMES")?.split(",") ?? [],
|
|
||||||
pausedReason: config?.pausedReason ?? null,
|
pausedReason: config?.pausedReason ?? null,
|
||||||
maxUserJobs: config?.maxUserJobs ?? Infinity,
|
maxUserJobs: config?.maxUserJobs ?? Infinity,
|
||||||
maxJobs: config?.maxJobs ?? Infinity,
|
maxJobs: config?.maxJobs ?? Infinity,
|
||||||
|
@ -42,7 +40,6 @@ export async function getConfig(): Promise<Config> {
|
||||||
export async function setConfig(newConfig: Partial<Config>): Promise<void> {
|
export async function setConfig(newConfig: Partial<Config>): Promise<void> {
|
||||||
const oldConfig = await getConfig();
|
const oldConfig = await getConfig();
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
adminUsernames: newConfig.adminUsernames ?? oldConfig.adminUsernames,
|
|
||||||
pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason,
|
pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason,
|
||||||
maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs,
|
maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs,
|
||||||
maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs,
|
maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { CommandContext } from "grammy";
|
||||||
import { bold, fmt, FormattedString } from "grammy_parse_mode";
|
import { bold, fmt, FormattedString } from "grammy_parse_mode";
|
||||||
import { distinctBy } from "std/collections/distinct_by.ts";
|
import { distinctBy } from "std/collections/distinct_by.ts";
|
||||||
import { error, info } from "std/log/mod.ts";
|
import { error, info } from "std/log/mod.ts";
|
||||||
import { getConfig } from "../app/config.ts";
|
import { adminStore } from "../app/adminStore.ts";
|
||||||
import { generationStore } from "../app/generationStore.ts";
|
import { generationStore } from "../app/generationStore.ts";
|
||||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||||
import { ErisContext } from "./mod.ts";
|
import { ErisContext } from "./mod.ts";
|
||||||
|
@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
|
||||||
return ctx.reply("I don't know who you are.");
|
return ctx.reply("I don't know who you are.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id });
|
||||||
|
|
||||||
if (!config.adminUsernames.includes(ctx.from.username)) {
|
if (!admin) {
|
||||||
return ctx.reply("Only a bot admin can use this command.");
|
return ctx.reply("Only a bot admin can use this command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
25
bot/mod.ts
25
bot/mod.ts
|
@ -4,7 +4,6 @@ import { hydrateReply, ParseModeFlavor } from "grammy_parse_mode";
|
||||||
import { run, sequentialize } from "grammy_runner";
|
import { run, sequentialize } from "grammy_runner";
|
||||||
import { error, info, warning } from "std/log/mod.ts";
|
import { error, info, warning } from "std/log/mod.ts";
|
||||||
import { sessions } from "../api/sessionsRoute.ts";
|
import { sessions } from "../api/sessionsRoute.ts";
|
||||||
import { getConfig, setConfig } from "../app/config.ts";
|
|
||||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||||
import { omitUndef } from "../utils/omitUndef.ts";
|
import { omitUndef } from "../utils/omitUndef.ts";
|
||||||
import { broadcastCommand } from "./broadcastCommand.ts";
|
import { broadcastCommand } from "./broadcastCommand.ts";
|
||||||
|
@ -170,30 +169,6 @@ bot.command("cancel", cancelCommand);
|
||||||
|
|
||||||
bot.command("broadcast", broadcastCommand);
|
bot.command("broadcast", broadcastCommand);
|
||||||
|
|
||||||
bot.command("pause", async (ctx) => {
|
|
||||||
if (!ctx.from?.username) return;
|
|
||||||
const config = await getConfig();
|
|
||||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
|
||||||
if (config.pausedReason != null) {
|
|
||||||
return ctx.reply(`Already paused: ${config.pausedReason}`);
|
|
||||||
}
|
|
||||||
await setConfig({
|
|
||||||
pausedReason: ctx.match || "No reason given",
|
|
||||||
});
|
|
||||||
warning(`Bot paused by ${ctx.from.first_name} because ${config.pausedReason}`);
|
|
||||||
return ctx.reply("Paused");
|
|
||||||
});
|
|
||||||
|
|
||||||
bot.command("resume", async (ctx) => {
|
|
||||||
if (!ctx.from?.username) return;
|
|
||||||
const config = await getConfig();
|
|
||||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
|
||||||
if (config.pausedReason == null) return ctx.reply("Already running");
|
|
||||||
await setConfig({ pausedReason: null });
|
|
||||||
info(`Bot resumed by ${ctx.from.first_name}`);
|
|
||||||
return ctx.reply("Resumed");
|
|
||||||
});
|
|
||||||
|
|
||||||
bot.command("crash", () => {
|
bot.command("crash", () => {
|
||||||
throw new Error("Crash command used");
|
throw new Error("Crash command used");
|
||||||
});
|
});
|
||||||
|
|
|
@ -45,8 +45,7 @@
|
||||||
"t_rest/server": "https://esm.sh/ty-rest@0.4.1/server",
|
"t_rest/server": "https://esm.sh/ty-rest@0.4.1/server",
|
||||||
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
||||||
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
||||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts",
|
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
|
||||||
"use-local-storage": "https://esm.sh/use-local-storage@3.0.0?external=react&dev"
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|
|
@ -0,0 +1,250 @@
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { AdminData } from "../api/adminsRoute.ts";
|
||||||
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
|
export function AdminsPage(props: { sessionId: string | null }) {
|
||||||
|
const { sessionId } = props;
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
const getSession = useSWR(
|
||||||
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
const getUser = useSWR(
|
||||||
|
getSession.data?.userId
|
||||||
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
|
: null,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getAdmins = useSWR(
|
||||||
|
["admins", "GET", {}] as const,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{getUser.data && getAdmins.data && getAdmins.data.length === 0 && (
|
||||||
|
<button
|
||||||
|
className="button-filled"
|
||||||
|
onClick={() => {
|
||||||
|
mutate(
|
||||||
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
|
async () =>
|
||||||
|
fetchApi("admins/promote_self", "POST", {
|
||||||
|
query: { sessionId: sessionId ?? "" },
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Promote me to admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getAdmins.data?.length
|
||||||
|
? (
|
||||||
|
<ul className="my-4 flex flex-col gap-2">
|
||||||
|
{getAdmins.data.map((admin) => (
|
||||||
|
<AdminListItem key={admin.id} admin={admin} sessionId={sessionId} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
: getAdmins.data?.length === 0
|
||||||
|
? <p>No admins</p>
|
||||||
|
: getAdmins.error
|
||||||
|
? <p className="alert">Loading admins failed</p>
|
||||||
|
: <div className="spinner self-center" />}
|
||||||
|
|
||||||
|
{getUser.data?.admin && (
|
||||||
|
<button
|
||||||
|
className="button-filled"
|
||||||
|
onClick={() => addDialogRef.current?.showModal()}
|
||||||
|
>
|
||||||
|
Add admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddAdminDialog
|
||||||
|
dialogRef={addDialogRef}
|
||||||
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddAdminDialog(props: {
|
||||||
|
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const { dialogRef, sessionId } = props;
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||||
|
<form
|
||||||
|
method="dialog"
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.target as HTMLFormElement);
|
||||||
|
mutate(
|
||||||
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
|
async () =>
|
||||||
|
fetchApi("admins", "POST", {
|
||||||
|
query: { sessionId: sessionId! },
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
tgUserId: Number(data.get("tgUserId") as string),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex flex-col items-stretch gap-1">
|
||||||
|
<span className="text-sm">
|
||||||
|
Telegram user ID
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
type="text"
|
||||||
|
name="tgUserId"
|
||||||
|
required
|
||||||
|
pattern="-?\d+"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-outlined"
|
||||||
|
onClick={() => dialogRef.current?.close()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="button-filled">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||||
|
const { admin, sessionId } = props;
|
||||||
|
|
||||||
|
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
const getAdminUser = useSWR(
|
||||||
|
["users/{userId}", "GET", { params: { userId: String(admin.tgUserId) } }] as const,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSession = useSWR(
|
||||||
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
const getUser = useSWR(
|
||||||
|
getSession.data?.userId
|
||||||
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
|
: null,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||||
|
<p className="font-bold">
|
||||||
|
{getAdminUser.data?.first_name ?? admin.id} {getAdminUser.data?.last_name}{" "}
|
||||||
|
{getAdminUser.data?.username
|
||||||
|
? (
|
||||||
|
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
|
||||||
|
@{getAdminUser.data.username}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
{getAdminUser.data?.bio
|
||||||
|
? (
|
||||||
|
<p>
|
||||||
|
{getAdminUser.data?.bio}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{getUser.data?.admin && (
|
||||||
|
<p className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="button-outlined"
|
||||||
|
onClick={() => deleteDialogRef.current?.showModal()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DeleteAdminDialog
|
||||||
|
dialogRef={deleteDialogRef}
|
||||||
|
adminId={admin.id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteAdminDialog(props: {
|
||||||
|
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||||
|
adminId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const { dialogRef, adminId, sessionId } = props;
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="dialog"
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutate(
|
||||||
|
(key) => Array.isArray(key) && key[0] === "admins",
|
||||||
|
async () =>
|
||||||
|
fetchApi("admins/{adminId}", "DELETE", {
|
||||||
|
query: { sessionId: sessionId! },
|
||||||
|
params: { adminId: adminId },
|
||||||
|
}).then(handleResponse),
|
||||||
|
{ populateCache: false },
|
||||||
|
);
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete this admin?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-outlined"
|
||||||
|
onClick={() => dialogRef.current?.close()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="button-filled">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
10
ui/App.tsx
10
ui/App.tsx
|
@ -1,16 +1,17 @@
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import useLocalStorage from "use-local-storage";
|
import { AdminsPage } from "./AdminsPage.tsx";
|
||||||
import { AppHeader } from "./AppHeader.tsx";
|
import { AppHeader } from "./AppHeader.tsx";
|
||||||
import { QueuePage } from "./QueuePage.tsx";
|
import { QueuePage } from "./QueuePage.tsx";
|
||||||
import { SettingsPage } from "./SettingsPage.tsx";
|
import { SettingsPage } from "./SettingsPage.tsx";
|
||||||
import { StatsPage } from "./StatsPage.tsx";
|
import { StatsPage } from "./StatsPage.tsx";
|
||||||
import { WorkersPage } from "./WorkersPage.tsx";
|
import { WorkersPage } from "./WorkersPage.tsx";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
import { useLocalStorage } from "./useLocalStorage.ts";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// store session ID in the local storage
|
// store session ID in the local storage
|
||||||
const [sessionId, setSessionId] = useLocalStorage("sessionId", "");
|
const [sessionId, setSessionId] = useLocalStorage("sessionId");
|
||||||
|
|
||||||
// initialize a new session when there is no session ID
|
// initialize a new session when there is no session ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -27,11 +28,12 @@ export function App() {
|
||||||
<AppHeader
|
<AppHeader
|
||||||
className="self-stretch"
|
className="self-stretch"
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onLogOut={() => setSessionId("")}
|
onLogOut={() => setSessionId(null)}
|
||||||
/>
|
/>
|
||||||
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
|
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<StatsPage />} />
|
<Route path="/" element={<StatsPage />} />
|
||||||
|
<Route path="/admins" element={<AdminsPage sessionId={sessionId} />} />
|
||||||
<Route path="/workers" element={<WorkersPage sessionId={sessionId} />} />
|
<Route path="/workers" element={<WorkersPage sessionId={sessionId} />} />
|
||||||
<Route path="/queue" element={<QueuePage />} />
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />
|
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React, { ReactNode } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { cx } from "twind/core";
|
import { cx } from "twind/core";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
function NavTab(props: { to: string; children: ReactNode }) {
|
function NavTab(props: { to: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
@ -15,33 +15,35 @@ function NavTab(props: { to: string; children: ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader(
|
export function AppHeader(props: {
|
||||||
props: { className?: string; sessionId?: string; onLogOut: () => void },
|
className?: string;
|
||||||
) {
|
sessionId: string | null;
|
||||||
|
onLogOut: () => void;
|
||||||
|
}) {
|
||||||
const { className, sessionId, onLogOut } = props;
|
const { className, sessionId, onLogOut } = props;
|
||||||
|
|
||||||
const session = useSWR(
|
const getSession = useSWR(
|
||||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ onError: () => onLogOut() },
|
{ onError: () => onLogOut() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = useSWR(
|
const getUser = useSWR(
|
||||||
session.data?.userId
|
getSession.data?.userId
|
||||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
const bot = useSWR(
|
const getBot = useSWR(
|
||||||
["bot", "GET", {}] as const,
|
["bot", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
const userPhoto = useSWR(
|
const getUserPhoto = useSWR(
|
||||||
session.data?.userId
|
getSession.data?.userId
|
||||||
? ["users/{userId}/photo", "GET", {
|
? ["users/{userId}/photo", "GET", {
|
||||||
params: { userId: String(session.data.userId) },
|
params: { userId: String(getSession.data.userId) },
|
||||||
}] as const
|
}] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)),
|
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)),
|
||||||
|
@ -62,6 +64,9 @@ export function AppHeader(
|
||||||
<NavTab to="/">
|
<NavTab to="/">
|
||||||
Stats
|
Stats
|
||||||
</NavTab>
|
</NavTab>
|
||||||
|
<NavTab to="/admins">
|
||||||
|
Admins
|
||||||
|
</NavTab>
|
||||||
<NavTab to="/workers">
|
<NavTab to="/workers">
|
||||||
Workers
|
Workers
|
||||||
</NavTab>
|
</NavTab>
|
||||||
|
@ -74,29 +79,29 @@ export function AppHeader(
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* loading indicator */}
|
{/* loading indicator */}
|
||||||
{session.isLoading || user.isLoading ? <div className="spinner" /> : null}
|
{getSession.isLoading || getUser.isLoading ? <div className="spinner" /> : null}
|
||||||
|
|
||||||
{/* user avatar */}
|
{/* user avatar */}
|
||||||
{user.data
|
{getUser.data
|
||||||
? userPhoto.data
|
? getUserPhoto.data
|
||||||
? (
|
? (
|
||||||
<img
|
<img
|
||||||
src={userPhoto.data}
|
src={getUserPhoto.data}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
className="w-9 h-9 rounded-full"
|
className="w-9 h-9 rounded-full"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none">
|
<div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none">
|
||||||
{user.data.first_name.at(0)?.toUpperCase()}
|
{getUser.data.first_name.at(0)?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{/* login/logout button */}
|
{/* login/logout button */}
|
||||||
{!session.isLoading && !user.isLoading && bot.data && sessionId
|
{!getSession.isLoading && !getUser.isLoading && getBot.data && sessionId
|
||||||
? (
|
? (
|
||||||
user.data
|
getUser.data
|
||||||
? (
|
? (
|
||||||
<button className="button-outlined" onClick={() => onLogOut()}>
|
<button className="button-outlined" onClick={() => onLogOut()}>
|
||||||
Logout
|
Logout
|
||||||
|
@ -105,7 +110,7 @@ export function AppHeader(
|
||||||
: (
|
: (
|
||||||
<a
|
<a
|
||||||
className="button-filled"
|
className="button-filled"
|
||||||
href={`https://t.me/${bot.data.username}?start=${sessionId}`}
|
href={`https://t.me/${getBot.data.username}?start=${sessionId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
|
|
|
@ -3,10 +3,10 @@ import FlipMove from "react-flip-move";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||||
import { Progress } from "./Progress.tsx";
|
import { Progress } from "./Progress.tsx";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
const jobs = useSWR(
|
const getJobs = useSWR(
|
||||||
["jobs", "GET", {}] as const,
|
["jobs", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 2000 },
|
{ refreshInterval: 2000 },
|
||||||
|
@ -19,7 +19,7 @@ export function QueuePage() {
|
||||||
enterAnimation="fade"
|
enterAnimation="fade"
|
||||||
leaveAnimation="fade"
|
leaveAnimation="fade"
|
||||||
>
|
>
|
||||||
{jobs.data?.map((job) => (
|
{getJobs.data?.map((job) => (
|
||||||
<li
|
<li
|
||||||
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl"
|
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl"
|
||||||
key={job.id.join("/")}
|
key={job.id.join("/")}
|
||||||
|
|
|
@ -1,79 +1,82 @@
|
||||||
import React, { ReactNode, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { cx } from "twind/core";
|
import { cx } from "twind/core";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
export function SettingsPage(props: { sessionId: string }) {
|
export function SettingsPage(props: { sessionId: string | null }) {
|
||||||
const { sessionId } = props;
|
const { sessionId } = props;
|
||||||
const session = useSWR(
|
|
||||||
|
const getSession = useSWR(
|
||||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
const user = useSWR(
|
const getUser = useSWR(
|
||||||
session.data?.userId
|
getSession.data?.userId
|
||||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
const params = useSWR(
|
const getParams = useSWR(
|
||||||
["settings/params", "GET", {}] as const,
|
["settings/params", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
const [changedParams, setChangedParams] = useState<Partial<typeof params.data>>({});
|
const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({});
|
||||||
const [error, setError] = useState<string>();
|
const [patchParamsError, setPatchParamsError] = useState<string>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col items-stretch gap-4"
|
className="flex flex-col items-stretch gap-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
params.mutate(() =>
|
getParams.mutate(() =>
|
||||||
fetchApi("settings/params", "PATCH", {
|
fetchApi("settings/params", "PATCH", {
|
||||||
query: { sessionId },
|
query: { sessionId: sessionId ?? "" },
|
||||||
body: { type: "application/json", data: changedParams ?? {} },
|
body: { type: "application/json", data: newParams ?? {} },
|
||||||
}).then(handleResponse)
|
}).then(handleResponse)
|
||||||
)
|
)
|
||||||
.then(() => setChangedParams({}))
|
.then(() => setNewParams({}))
|
||||||
.catch((e) => setError(String(e)));
|
.catch((e) => setPatchParamsError(String(e)));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="flex flex-col items-stretch gap-1">
|
<label className="flex flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Negative prompt {changedParams?.negative_prompt != null ? "(Changed)" : ""}
|
Negative prompt {newParams?.negative_prompt != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="input-text"
|
className="input-text"
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.negative_prompt ??
|
value={newParams?.negative_prompt ??
|
||||||
params.data?.negative_prompt ??
|
getParams.data?.negative_prompt ??
|
||||||
""}
|
""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
negative_prompt: e.target.value,
|
negative_prompt: e.target.value,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col items-stretch gap-1">
|
<label className="flex flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Sampler {changedParams?.sampler_name != null ? "(Changed)" : ""}
|
Sampler {newParams?.sampler_name != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="input-text"
|
className="input-text"
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.sampler_name ??
|
value={newParams?.sampler_name ??
|
||||||
params.data?.sampler_name ??
|
getParams.data?.sampler_name ??
|
||||||
""}
|
""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
sampler_name: e.target.value,
|
sampler_name: e.target.value,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col items-stretch gap-1">
|
<label className="flex flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Steps {changedParams?.steps != null ? "(Changed)" : ""}
|
Steps {newParams?.steps != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
|
@ -82,12 +85,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={5}
|
min={5}
|
||||||
max={50}
|
max={50}
|
||||||
step={5}
|
step={5}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.steps ??
|
value={newParams?.steps ??
|
||||||
params.data?.steps ??
|
getParams.data?.steps ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
steps: Number(e.target.value),
|
steps: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
|
@ -98,21 +101,22 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={5}
|
min={5}
|
||||||
max={50}
|
max={50}
|
||||||
step={5}
|
step={5}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.steps ??
|
value={newParams?.steps ??
|
||||||
params.data?.steps ??
|
getParams.data?.steps ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
steps: Number(e.target.value),
|
steps: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col items-stretch gap-1">
|
<label className="flex flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Detail {changedParams?.cfg_scale != null ? "(Changed)" : ""}
|
Detail {newParams?.cfg_scale != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
|
@ -121,12 +125,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
step={1}
|
step={1}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.cfg_scale ??
|
value={newParams?.cfg_scale ??
|
||||||
params.data?.cfg_scale ??
|
getParams.data?.cfg_scale ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
cfg_scale: Number(e.target.value),
|
cfg_scale: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
|
@ -137,22 +141,23 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
step={1}
|
step={1}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.cfg_scale ??
|
value={newParams?.cfg_scale ??
|
||||||
params.data?.cfg_scale ??
|
getParams.data?.cfg_scale ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
cfg_scale: Number(e.target.value),
|
cfg_scale: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Width {changedParams?.width != null ? "(Changed)" : ""}
|
Width {newParams?.width != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="input-text"
|
className="input-text"
|
||||||
|
@ -160,12 +165,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={64}
|
min={64}
|
||||||
max={2048}
|
max={2048}
|
||||||
step={64}
|
step={64}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.width ??
|
value={newParams?.width ??
|
||||||
params.data?.width ??
|
getParams.data?.width ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
width: Number(e.target.value),
|
width: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
|
@ -176,12 +181,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={64}
|
min={64}
|
||||||
max={2048}
|
max={2048}
|
||||||
step={64}
|
step={64}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.width ??
|
value={newParams?.width ??
|
||||||
params.data?.width ??
|
getParams.data?.width ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
width: Number(e.target.value),
|
width: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
|
@ -189,7 +194,7 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Height {changedParams?.height != null ? "(Changed)" : ""}
|
Height {newParams?.height != null ? "(Changed)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="input-text"
|
className="input-text"
|
||||||
|
@ -197,12 +202,12 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={64}
|
min={64}
|
||||||
max={2048}
|
max={2048}
|
||||||
step={64}
|
step={64}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.height ??
|
value={newParams?.height ??
|
||||||
params.data?.height ??
|
getParams.data?.height ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
height: Number(e.target.value),
|
height: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
|
@ -213,35 +218,54 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
min={64}
|
min={64}
|
||||||
max={2048}
|
max={2048}
|
||||||
step={64}
|
step={64}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin}
|
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||||
value={changedParams?.height ??
|
value={newParams?.height ??
|
||||||
params.data?.height ??
|
getParams.data?.height ??
|
||||||
0}
|
0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setChangedParams((params) => ({
|
setNewParams((params) => ({
|
||||||
...params,
|
...params,
|
||||||
height: Number(e.target.value),
|
height: Number(e.target.value),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{error ? <Alert onClose={() => setError(undefined)}>{error}</Alert> : null}
|
|
||||||
{params.error ? <Alert>{params.error.message}</Alert> : null}
|
{patchParamsError
|
||||||
|
? (
|
||||||
|
<p className="alert">
|
||||||
|
<span className="flex-grow">Updating params failed: {patchParamsError}</span>
|
||||||
|
<button className="button-ghost" onClick={() => setPatchParamsError(undefined)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{getParams.error
|
||||||
|
? (
|
||||||
|
<p className="alert">
|
||||||
|
<span className="flex-grow">
|
||||||
|
Loading params failed: {String(getParams.error)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
<div className="flex gap-2 items-center justify-end">
|
<div className="flex gap-2 items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cx("button-outlined ripple", params.isLoading && "bg-stripes")}
|
className={cx("button-outlined ripple", getParams.isLoading && "bg-stripes")}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||||
Object.keys(changedParams ?? {}).length === 0}
|
Object.keys(newParams ?? {}).length === 0}
|
||||||
onClick={() => setChangedParams({})}
|
onClick={() => setNewParams({})}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={cx("button-filled ripple", params.isLoading && "bg-stripes")}
|
className={cx("button-filled ripple", getParams.isLoading && "bg-stripes")}
|
||||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||||
Object.keys(changedParams ?? {}).length === 0}
|
Object.keys(newParams ?? {}).length === 0}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
@ -249,26 +273,3 @@ export function SettingsPage(props: { sessionId: string }) {
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Alert(props: { children: ReactNode; onClose?: () => void }) {
|
|
||||||
const { children, onClose } = props;
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
className="px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md"
|
|
||||||
>
|
|
||||||
<span className="flex-grow">{children}</span>
|
|
||||||
{onClose
|
|
||||||
? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-ghost"
|
|
||||||
onClick={() => onClose()}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Counter } from "./Counter.tsx";
|
import { Counter } from "./Counter.tsx";
|
||||||
|
|
||||||
export function StatsPage() {
|
export function StatsPage() {
|
||||||
const globalStats = useSWR(
|
const getGlobalStats = useSWR(
|
||||||
["stats", "GET", {}] as const,
|
["stats", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 2_000 },
|
{ refreshInterval: 2_000 },
|
||||||
|
@ -16,13 +16,13 @@ export function StatsPage() {
|
||||||
<span>Pixelsteps diffused</span>
|
<span>Pixelsteps diffused</span>
|
||||||
<Counter
|
<Counter
|
||||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
value={globalStats.data?.pixelStepCount ?? 0}
|
value={getGlobalStats.data?.pixelStepCount ?? 0}
|
||||||
digits={15}
|
digits={15}
|
||||||
transitionDurationMs={3_000}
|
transitionDurationMs={3_000}
|
||||||
/>
|
/>
|
||||||
<Counter
|
<Counter
|
||||||
className="text-base"
|
className="text-base"
|
||||||
value={(globalStats.data?.pixelStepsPerMinute ?? 0) / 60}
|
value={(getGlobalStats.data?.pixelStepsPerMinute ?? 0) / 60}
|
||||||
digits={9}
|
digits={9}
|
||||||
transitionDurationMs={2_000}
|
transitionDurationMs={2_000}
|
||||||
postfix="/s"
|
postfix="/s"
|
||||||
|
@ -32,13 +32,13 @@ export function StatsPage() {
|
||||||
<span>Pixels painted</span>
|
<span>Pixels painted</span>
|
||||||
<Counter
|
<Counter
|
||||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
value={globalStats.data?.pixelCount ?? 0}
|
value={getGlobalStats.data?.pixelCount ?? 0}
|
||||||
digits={15}
|
digits={15}
|
||||||
transitionDurationMs={3_000}
|
transitionDurationMs={3_000}
|
||||||
/>
|
/>
|
||||||
<Counter
|
<Counter
|
||||||
className="text-base"
|
className="text-base"
|
||||||
value={(globalStats.data?.pixelsPerMinute ?? 0) / 60}
|
value={(getGlobalStats.data?.pixelsPerMinute ?? 0) / 60}
|
||||||
digits={9}
|
digits={9}
|
||||||
transitionDurationMs={2_000}
|
transitionDurationMs={2_000}
|
||||||
postfix="/s"
|
postfix="/s"
|
||||||
|
@ -49,13 +49,13 @@ export function StatsPage() {
|
||||||
<span>Steps processed</span>
|
<span>Steps processed</span>
|
||||||
<Counter
|
<Counter
|
||||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
value={globalStats.data?.stepCount ?? 0}
|
value={getGlobalStats.data?.stepCount ?? 0}
|
||||||
digits={9}
|
digits={9}
|
||||||
transitionDurationMs={3_000}
|
transitionDurationMs={3_000}
|
||||||
/>
|
/>
|
||||||
<Counter
|
<Counter
|
||||||
className="text-base"
|
className="text-base"
|
||||||
value={(globalStats.data?.stepsPerMinute ?? 0) / 60}
|
value={(getGlobalStats.data?.stepsPerMinute ?? 0) / 60}
|
||||||
digits={3}
|
digits={3}
|
||||||
fractionDigits={3}
|
fractionDigits={3}
|
||||||
transitionDurationMs={2_000}
|
transitionDurationMs={2_000}
|
||||||
|
@ -66,13 +66,13 @@ export function StatsPage() {
|
||||||
<span>Images generated</span>
|
<span>Images generated</span>
|
||||||
<Counter
|
<Counter
|
||||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
value={globalStats.data?.imageCount ?? 0}
|
value={getGlobalStats.data?.imageCount ?? 0}
|
||||||
digits={9}
|
digits={9}
|
||||||
transitionDurationMs={3_000}
|
transitionDurationMs={3_000}
|
||||||
/>
|
/>
|
||||||
<Counter
|
<Counter
|
||||||
className="text-base"
|
className="text-base"
|
||||||
value={(globalStats.data?.imagesPerMinute ?? 0) / 60}
|
value={(getGlobalStats.data?.imagesPerMinute ?? 0) / 60}
|
||||||
digits={3}
|
digits={3}
|
||||||
fractionDigits={3}
|
fractionDigits={3}
|
||||||
transitionDurationMs={2_000}
|
transitionDurationMs={2_000}
|
||||||
|
@ -84,7 +84,7 @@ export function StatsPage() {
|
||||||
<span>Unique users</span>
|
<span>Unique users</span>
|
||||||
<Counter
|
<Counter
|
||||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
value={globalStats.data?.userCount ?? 0}
|
value={getGlobalStats.data?.userCount ?? 0}
|
||||||
digits={6}
|
digits={6}
|
||||||
transitionDurationMs={1_500}
|
transitionDurationMs={1_500}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
import React, { useRef } from "react";
|
import React, { RefObject, useRef, useState } from "react";
|
||||||
import { FormattedRelativeTime } from "react-intl";
|
import { FormattedRelativeTime } from "react-intl";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { WorkerData } from "../api/workersRoute.ts";
|
import { WorkerData } from "../api/workersRoute.ts";
|
||||||
import { Counter } from "./Counter.tsx";
|
import { Counter } from "./Counter.tsx";
|
||||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||||
|
|
||||||
export function WorkersPage(props: { sessionId?: string }) {
|
export function WorkersPage(props: { sessionId: string | null }) {
|
||||||
const { sessionId } = props;
|
const { sessionId } = props;
|
||||||
|
|
||||||
const createWorkerModalRef = useRef<HTMLDialogElement>(null);
|
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
const session = useSWR(
|
const getSession = useSWR(
|
||||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
const user = useSWR(
|
const getUser = useSWR(
|
||||||
session.data?.userId
|
getSession.data?.userId
|
||||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
const workers = useSWR(
|
const getWorkers = useSWR(
|
||||||
["workers", "GET", {}] as const,
|
["workers", "GET", {}] as const,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
{ refreshInterval: 5000 },
|
{ refreshInterval: 5000 },
|
||||||
|
@ -29,23 +29,47 @@ export function WorkersPage(props: { sessionId?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{getWorkers.data?.length
|
||||||
|
? (
|
||||||
<ul className="my-4 flex flex-col gap-2">
|
<ul className="my-4 flex flex-col gap-2">
|
||||||
{workers.data?.map((worker) => (
|
{getWorkers.data?.map((worker) => (
|
||||||
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{user.data?.isAdmin && (
|
)
|
||||||
|
: getWorkers.data?.length === 0
|
||||||
|
? <p>No workers</p>
|
||||||
|
: getWorkers.error
|
||||||
|
? <p className="alert">Loading workers failed</p>
|
||||||
|
: <div className="spinner self-center" />}
|
||||||
|
|
||||||
|
{getUser.data?.admin && (
|
||||||
<button
|
<button
|
||||||
className="button-filled"
|
className="button-filled"
|
||||||
onClick={() => createWorkerModalRef.current?.showModal()}
|
onClick={() => addDialogRef.current?.showModal()}
|
||||||
>
|
>
|
||||||
Add worker
|
Add worker
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<dialog
|
|
||||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
<AddWorkerDialog
|
||||||
ref={createWorkerModalRef}
|
dialogRef={addDialogRef}
|
||||||
>
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddWorkerDialog(props: {
|
||||||
|
dialogRef: RefObject<HTMLDialogElement>;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const { dialogRef, sessionId } = props;
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||||
<form
|
<form
|
||||||
method="dialog"
|
method="dialog"
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
|
@ -58,8 +82,10 @@ export function WorkersPage(props: { sessionId?: string }) {
|
||||||
const user = data.get("user") as string;
|
const user = data.get("user") as string;
|
||||||
const password = data.get("password") as string;
|
const password = data.get("password") as string;
|
||||||
console.log(key, name, user, password);
|
console.log(key, name, user, password);
|
||||||
workers.mutate(async () => {
|
mutate(
|
||||||
const worker = await fetchApi("workers", "POST", {
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
|
fetchApi("workers", "POST", {
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
body: {
|
body: {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
|
@ -70,11 +96,10 @@ export function WorkersPage(props: { sessionId?: string }) {
|
||||||
sdAuth: user && password ? { user, password } : null,
|
sdAuth: user && password ? { user, password } : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).then(handleResponse);
|
}).then(handleResponse),
|
||||||
return [...(workers.data ?? []), worker];
|
{ populateCache: false },
|
||||||
});
|
);
|
||||||
|
dialogRef.current?.close();
|
||||||
createWorkerModalRef.current?.close();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
@ -145,7 +170,7 @@ export function WorkersPage(props: { sessionId?: string }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-outlined"
|
className="button-outlined"
|
||||||
onClick={() => createWorkerModalRef.current?.close()}
|
onClick={() => dialogRef.current?.close()}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
@ -155,28 +180,28 @@ export function WorkersPage(props: { sessionId?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefined }) {
|
function WorkerListItem(props: {
|
||||||
|
worker: WorkerData;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
const { worker, sessionId } = props;
|
const { worker, sessionId } = props;
|
||||||
const editWorkerModalRef = useRef<HTMLDialogElement>(null);
|
const editDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const deleteWorkerModalRef = useRef<HTMLDialogElement>(null);
|
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
const session = useSWR(
|
const getSession = useSWR(
|
||||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
const user = useSWR(
|
const getUser = useSWR(
|
||||||
session.data?.userId
|
getSession.data?.userId
|
||||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
|
||||||
: null,
|
: null,
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||||
<p className="font-bold">
|
<p className="font-bold">
|
||||||
|
@ -212,27 +237,49 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
{" "}
|
{" "}
|
||||||
<Counter value={worker.stepsPerMinute} digits={3} /> steps per minute
|
<Counter value={worker.stepsPerMinute} digits={3} /> steps per minute
|
||||||
</p>
|
</p>
|
||||||
|
{getUser.data?.admin && (
|
||||||
<p className="flex gap-2">
|
<p className="flex gap-2">
|
||||||
{user.data?.isAdmin && (
|
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
className="button-outlined"
|
className="button-outlined"
|
||||||
onClick={() => editWorkerModalRef.current?.showModal()}
|
onClick={() => editDialogRef.current?.showModal()}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button-outlined"
|
className="button-outlined"
|
||||||
onClick={() => deleteWorkerModalRef.current?.showModal()}
|
onClick={() => deleteDialogRef.current?.showModal()}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
<EditWorkerDialog
|
||||||
|
dialogRef={editDialogRef}
|
||||||
|
workerId={worker.id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
<DeleteWorkerDialog
|
||||||
|
dialogRef={deleteDialogRef}
|
||||||
|
workerId={worker.id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditWorkerDialog(props: {
|
||||||
|
dialogRef: RefObject<HTMLDialogElement>;
|
||||||
|
workerId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const { dialogRef, workerId, sessionId } = props;
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||||
ref={editWorkerModalRef}
|
ref={dialogRef}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
method="dialog"
|
method="dialog"
|
||||||
|
@ -243,8 +290,11 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
const user = data.get("user") as string;
|
const user = data.get("user") as string;
|
||||||
const password = data.get("password") as string;
|
const password = data.get("password") as string;
|
||||||
console.log(user, password);
|
console.log(user, password);
|
||||||
|
mutate(
|
||||||
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
fetchApi("workers/{workerId}", "PATCH", {
|
fetchApi("workers/{workerId}", "PATCH", {
|
||||||
params: { workerId: worker.id },
|
params: { workerId: workerId },
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
body: {
|
body: {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
|
@ -252,8 +302,10 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
sdAuth: user && password ? { user, password } : null,
|
sdAuth: user && password ? { user, password } : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}).then(handleResponse),
|
||||||
editWorkerModalRef.current?.close();
|
{ populateCache: false },
|
||||||
|
);
|
||||||
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
@ -282,7 +334,7 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-outlined"
|
className="button-outlined"
|
||||||
onClick={() => editWorkerModalRef.current?.close()}
|
onClick={() => dialogRef.current?.close()}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
@ -292,20 +344,37 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteWorkerDialog(props: {
|
||||||
|
dialogRef: RefObject<HTMLDialogElement>;
|
||||||
|
workerId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const { dialogRef, workerId, sessionId } = props;
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||||
ref={deleteWorkerModalRef}
|
ref={dialogRef}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
method="dialog"
|
method="dialog"
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
mutate(
|
||||||
|
(key) => Array.isArray(key) && key[0] === "workers",
|
||||||
|
async () =>
|
||||||
fetchApi("workers/{workerId}", "DELETE", {
|
fetchApi("workers/{workerId}", "DELETE", {
|
||||||
params: { workerId: worker.id },
|
params: { workerId: workerId },
|
||||||
query: { sessionId: sessionId! },
|
query: { sessionId: sessionId! },
|
||||||
}).then(handleResponse).then(() => mutate(["workers", "GET", {}]));
|
}).then(handleResponse),
|
||||||
deleteWorkerModalRef.current?.close();
|
{ populateCache: false },
|
||||||
|
);
|
||||||
|
dialogRef.current?.close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
|
@ -315,7 +384,7 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-outlined"
|
className="button-outlined"
|
||||||
onClick={() => deleteWorkerModalRef.current?.close()}
|
onClick={() => dialogRef.current?.close()}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
@ -325,6 +394,5 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
13
ui/twind.ts
13
ui/twind.ts
|
@ -3,9 +3,10 @@ import presetTailwind from "twind/preset-tailwind";
|
||||||
|
|
||||||
const twConfig = defineConfig({
|
const twConfig = defineConfig({
|
||||||
presets: [presetTailwind()],
|
presets: [presetTailwind()],
|
||||||
|
hash: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
install(twConfig);
|
install(twConfig, false);
|
||||||
|
|
||||||
injectGlobal`
|
injectGlobal`
|
||||||
@layer base {
|
@layer base {
|
||||||
|
@ -89,6 +90,10 @@ injectGlobal`
|
||||||
@apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current;
|
@apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
@apply px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
.button-filled {
|
.button-filled {
|
||||||
@apply rounded-md bg-sky-600 px-3 py-2 min-h-12
|
@apply rounded-md bg-sky-600 px-3 py-2 min-h-12
|
||||||
text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all
|
text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all
|
||||||
|
@ -111,7 +116,7 @@ injectGlobal`
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap focus:outline-none
|
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap outline-none
|
||||||
border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all;
|
border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,8 +136,8 @@ injectGlobal`
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
@apply overflow-hidden overflow-y-auto rounded-md
|
@apply overflow-hidden overflow-y-auto rounded-md shadow-xl
|
||||||
bg-zinc-100 text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
|
bg-white text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useLocalStorage(key: string) {
|
||||||
|
const [value, setValue] = useState(() => window.localStorage.getItem(key));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(key, value);
|
||||||
|
if (value != null) {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === key) {
|
||||||
|
console.log(key, event.newValue);
|
||||||
|
setValue(event.newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
};
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
Loading…
Reference in New Issue