forked from pinks/eris
feat: home page with counters
This commit is contained in:
parent
bf75cce20c
commit
0e88bc5053
|
@ -1,17 +1,26 @@
|
||||||
|
// deno-lint-ignore-file require-await
|
||||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { liveGlobalStats } from "../app/globalStatsStore.ts";
|
import { liveGlobalStats } from "../app/globalStatsStore.ts";
|
||||||
import { getDailyStats } from "../app/dailyStatsStore.ts";
|
import { getDailyStats } from "../app/dailyStatsStore.ts";
|
||||||
|
import { getUserStats } from "../app/userStatsStore.ts";
|
||||||
|
import { getUserDailyStats } from "../app/userDailyStatsStore.ts";
|
||||||
|
|
||||||
export const statsRoute = createPathFilter({
|
export const statsRoute = createPathFilter({
|
||||||
"global": createMethodFilter({
|
"": createMethodFilter({
|
||||||
GET: createEndpoint(
|
GET: createEndpoint(
|
||||||
{ query: null, body: null },
|
{ query: null, body: null },
|
||||||
async () => {
|
async () => {
|
||||||
|
const stats = liveGlobalStats;
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
data: liveGlobalStats,
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
userCount: stats.userIds.length,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -24,20 +33,61 @@ export const statsRoute = createPathFilter({
|
||||||
const year = Number(params.year);
|
const year = Number(params.year);
|
||||||
const month = Number(params.month);
|
const month = Number(params.month);
|
||||||
const day = Number(params.day);
|
const day = Number(params.day);
|
||||||
const minDate = new Date("2023-01-01");
|
const stats = await getDailyStats(year, month, day);
|
||||||
const maxDate = new Date();
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
|
||||||
if (date < minDate || date > maxDate) {
|
|
||||||
return {
|
|
||||||
status: 404,
|
|
||||||
body: { type: "text/plain", data: "Not found" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
data: await getDailyStats(year, month, day),
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
userCount: stats.userIds.length,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
"users/{userId}": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const userId = Number(params.userId);
|
||||||
|
const stats = await getUserStats(userId);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
tagCountMap: stats.tagCountMap,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
"users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const userId = Number(params.userId);
|
||||||
|
const year = Number(params.year);
|
||||||
|
const month = Number(params.month);
|
||||||
|
const day = Number(params.day);
|
||||||
|
const stats = await getUserDailyStats(userId, year, month, day);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "application/json",
|
||||||
|
data: {
|
||||||
|
imageCount: stats.imageCount,
|
||||||
|
pixelCount: stats.pixelCount,
|
||||||
|
timestamp: stats.timestamp,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { encode } from "std/encoding/base64.ts";
|
|
||||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||||
import { getConfig } from "../app/config.ts";
|
import { getConfig } from "../app/config.ts";
|
||||||
import { bot } from "../bot/mod.ts";
|
import { bot } from "../bot/mod.ts";
|
||||||
|
@ -12,22 +11,41 @@ export const usersRoute = createPathFilter({
|
||||||
if (chat.type !== "private") {
|
if (chat.type !== "private") {
|
||||||
throw new Error("Chat is not private");
|
throw new Error("Chat is not private");
|
||||||
}
|
}
|
||||||
const photoData = chat.photo?.small_file_id
|
|
||||||
? encode(
|
|
||||||
await fetch(
|
|
||||||
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
|
||||||
chat.photo.small_file_id,
|
|
||||||
).then((file) => file.file_path)}`,
|
|
||||||
).then((resp) => resp.arrayBuffer()),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const isAdmin = config?.adminUsernames?.includes(chat.username);
|
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, photoData, isAdmin },
|
data: { ...chat, isAdmin },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
"{userId}/photo": createMethodFilter({
|
||||||
|
GET: createEndpoint(
|
||||||
|
{ query: null, body: null },
|
||||||
|
async ({ params }) => {
|
||||||
|
const chat = await bot.api.getChat(params.userId);
|
||||||
|
if (chat.type !== "private") {
|
||||||
|
throw new Error("Chat is not private");
|
||||||
|
}
|
||||||
|
const photoData = chat.photo?.small_file_id
|
||||||
|
? await fetch(
|
||||||
|
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
||||||
|
chat.photo.small_file_id,
|
||||||
|
).then((file) => file.file_path)}`,
|
||||||
|
).then((resp) => resp.arrayBuffer())
|
||||||
|
: undefined;
|
||||||
|
if (!photoData) {
|
||||||
|
return { status: 404, body: { type: "text/plain", data: "User has no photo" } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
type: "image/jpeg",
|
||||||
|
data: new Blob([photoData], { type: "image/jpeg" }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { JsonSchema, jsonType } from "t_rest/server";
|
||||||
|
import { kvMemoize } from "./kvMemoize.ts";
|
||||||
|
import { db } from "./db.ts";
|
||||||
|
import { generationStore } from "./generationStore.ts";
|
||||||
|
|
||||||
|
export const userDailyStatsSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
imageCount: { type: "number" },
|
||||||
|
pixelCount: { type: "number" },
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["imageCount", "pixelCount", "timestamp"],
|
||||||
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
|
export type UserDailyStats = jsonType<typeof userDailyStatsSchema>;
|
||||||
|
|
||||||
|
export const getUserDailyStats = kvMemoize(
|
||||||
|
db,
|
||||||
|
["userDailyStats"],
|
||||||
|
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
},
|
||||||
|
);
|
|
@ -14,13 +14,13 @@ export const userStatsSchema = {
|
||||||
userId: { type: "number" },
|
userId: { type: "number" },
|
||||||
imageCount: { type: "number" },
|
imageCount: { type: "number" },
|
||||||
pixelCount: { type: "number" },
|
pixelCount: { type: "number" },
|
||||||
tagsCount: {
|
tagCountMap: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: { type: "number" },
|
additionalProperties: { type: "number" },
|
||||||
},
|
},
|
||||||
timestamp: { type: "number" },
|
timestamp: { type: "number" },
|
||||||
},
|
},
|
||||||
required: ["userId", "imageCount", "pixelCount", "tagsCount", "timestamp"],
|
required: ["userId", "imageCount", "pixelCount", "tagCountMap", "timestamp"],
|
||||||
} as const satisfies JsonSchema;
|
} as const satisfies JsonSchema;
|
||||||
|
|
||||||
export type UserStats = jsonType<typeof userStatsSchema>;
|
export type UserStats = jsonType<typeof userStatsSchema>;
|
||||||
|
@ -49,7 +49,7 @@ export const getUserStats = kvMemoize(
|
||||||
async (userId: number): Promise<UserStats> => {
|
async (userId: number): Promise<UserStats> => {
|
||||||
let imageCount = 0;
|
let imageCount = 0;
|
||||||
let pixelCount = 0;
|
let pixelCount = 0;
|
||||||
const tagsCount: Record<string, number> = {};
|
const tagCountMap: Record<string, number> = {};
|
||||||
|
|
||||||
logger().info(`Calculating user stats for ${userId}`);
|
logger().info(`Calculating user stats for ${userId}`);
|
||||||
|
|
||||||
|
@ -58,15 +58,27 @@ export const getUserStats = kvMemoize(
|
||||||
) {
|
) {
|
||||||
imageCount++;
|
imageCount++;
|
||||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
||||||
const tags = generation.value.info?.prompt.split(/[,;.\n]/)
|
|
||||||
|
const tags = generation.value.info?.prompt
|
||||||
|
// split on punctuation and newlines
|
||||||
|
.split(/[,;.]\s+|\n/)
|
||||||
|
// remove `:weight` syntax
|
||||||
|
.map((tag) => tag.replace(/:[\d\.]+/g, " "))
|
||||||
|
// remove `(tag)` and `[tag]` syntax
|
||||||
|
.map((tag) => tag.replace(/[()[\]]/g, " "))
|
||||||
|
// collapse multiple whitespace to one
|
||||||
|
.map((tag) => tag.replace(/\s+/g, " "))
|
||||||
|
// trim whitespace
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
|
// remove empty tags
|
||||||
.filter((tag) => tag.length > 0)
|
.filter((tag) => tag.length > 0)
|
||||||
.map((tag) => tag.toLowerCase())
|
// lowercase tags
|
||||||
.map((tag) => tag.replace(/[()[\]]/, ""))
|
.map((tag) => tag.toLowerCase()) ??
|
||||||
.map((tag) => tag.replace(/:[\d.]/g, ""))
|
// default to empty array
|
||||||
.map((tag) => tag.replace(/ +/g, " ")) ?? [];
|
[];
|
||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
tagsCount[tag] = (tagsCount[tag] ?? 0) + 1;
|
tagCountMap[tag] = (tagCountMap[tag] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +86,7 @@ export const getUserStats = kvMemoize(
|
||||||
userId,
|
userId,
|
||||||
imageCount,
|
imageCount,
|
||||||
pixelCount,
|
pixelCount,
|
||||||
tagsCount,
|
tagCountMap,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -36,9 +36,9 @@
|
||||||
"png_chunks_extract": "https://esm.sh/png-chunks-extract@1.0.0",
|
"png_chunks_extract": "https://esm.sh/png-chunks-extract@1.0.0",
|
||||||
"png_chunk_text": "https://esm.sh/png-chunk-text@1.0.0",
|
"png_chunk_text": "https://esm.sh/png-chunk-text@1.0.0",
|
||||||
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6",
|
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6",
|
||||||
"t_rest/server": "https://esm.sh/ty-rest@0.3.2/server?dev",
|
"t_rest/server": "https://esm.sh/ty-rest@0.4.0/server?dev",
|
||||||
|
|
||||||
"t_rest/client": "https://esm.sh/ty-rest@0.3.2/client?dev",
|
"t_rest/client": "https://esm.sh/ty-rest@0.4.0/client?dev",
|
||||||
"react": "https://esm.sh/react@18.2.0?dev",
|
"react": "https://esm.sh/react@18.2.0?dev",
|
||||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
||||||
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?dev",
|
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?dev",
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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 { fetchApi, handleResponse } from "./apiClient.tsx";
|
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||||
|
import { HomePage } from "./HomePage.tsx";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// store session ID in the local storage
|
// store session ID in the local storage
|
||||||
|
@ -37,7 +38,3 @@ export function App() {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomePage() {
|
|
||||||
return <h1>hi</h1>;
|
|
||||||
}
|
|
||||||
|
|
|
@ -33,6 +33,15 @@ export function AppHeader(
|
||||||
(args) => fetchApi(...args).then(handleResponse),
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const userPhoto = useSWR(
|
||||||
|
session.data?.userId
|
||||||
|
? ["users/{userId}/photo", "GET", {
|
||||||
|
params: { userId: String(session.data.userId) },
|
||||||
|
}] as const
|
||||||
|
: null,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cx(
|
className={cx(
|
||||||
|
@ -61,10 +70,10 @@ export function AppHeader(
|
||||||
|
|
||||||
{/* user avatar */}
|
{/* user avatar */}
|
||||||
{user.data
|
{user.data
|
||||||
? user.data.photoData
|
? userPhoto.data
|
||||||
? (
|
? (
|
||||||
<img
|
<img
|
||||||
src={`data:image/jpeg;base64,${user.data.photoData}`}
|
src={userPhoto.data}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
className="w-9 h-9 rounded-full"
|
className="w-9 h-9 rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { cx } from "@twind/core";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function CounterDigit(props: { value: number; transitionDurationMs?: number }) {
|
||||||
|
const { value, transitionDurationMs = 1000 } = props;
|
||||||
|
const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="w-[1em] h-[1.3em] relative">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="absolute inset-0 grid place-items-center transition-transform duration-1000 ease-in-out [backface-visibility:hidden]"
|
||||||
|
style={{
|
||||||
|
transform: `rotateX(${rads + i * 2 * Math.PI * 0.1}rad)`,
|
||||||
|
transformOrigin: `center center 1.8em`,
|
||||||
|
transitionDuration: `${transitionDurationMs}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Counter(props: {
|
||||||
|
value: number;
|
||||||
|
digits: number;
|
||||||
|
transitionDurationMs?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { value, digits, transitionDurationMs, className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"inline-flex items-stretch border-1 border-zinc-300 dark:border-zinc-700 shadow-inner rounded-md overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: digits })
|
||||||
|
.flatMap((_, i) => [
|
||||||
|
i > 0 && i % 3 === 0
|
||||||
|
? (
|
||||||
|
<span
|
||||||
|
key={`spacer${i}`}
|
||||||
|
className="border-l-1 mx-[0.1em] border-zinc-200 dark:border-zinc-700"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
<CounterDigit
|
||||||
|
key={`digit${i}`}
|
||||||
|
value={value / 10 ** i}
|
||||||
|
transitionDurationMs={transitionDurationMs}
|
||||||
|
/>,
|
||||||
|
])
|
||||||
|
.reverse()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from "react";
|
||||||
|
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { eachDayOfInterval, endOfMonth, startOfMonth, subMonths } from "date-fns";
|
||||||
|
import { UTCDateMini } from "@date-fns/utc";
|
||||||
|
import { Counter } from "./Counter.tsx";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const globalStats = useSWR(
|
||||||
|
["stats", "GET", {}] as const,
|
||||||
|
(args) => fetchApi(...args).then(handleResponse),
|
||||||
|
{ refreshInterval: 2_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-16 flex flex-col gap-16 text-zinc-600 dark:text-zinc-400">
|
||||||
|
<p className="flex flex-col items-center gap-2 text-2xl sm:text-3xl">
|
||||||
|
<span>Pixels painted</span>
|
||||||
|
<Counter
|
||||||
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
|
value={globalStats.data?.pixelCount ?? 0}
|
||||||
|
digits={12}
|
||||||
|
transitionDurationMs={3_000}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col md:flex-row gap-16 md:gap-8">
|
||||||
|
<p className="flex-grow flex flex-col items-center gap-2 text-2xl">
|
||||||
|
<span>Images generated</span>
|
||||||
|
<Counter
|
||||||
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
|
value={globalStats.data?.imageCount ?? 0}
|
||||||
|
digits={9}
|
||||||
|
transitionDurationMs={1_500}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p className="flex-grow flex flex-col items-center gap-2 text-2xl">
|
||||||
|
<span>Unique users</span>
|
||||||
|
<Counter
|
||||||
|
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||||
|
value={globalStats.data?.userCount ?? 0}
|
||||||
|
digits={6}
|
||||||
|
transitionDurationMs={1_500}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue