forked from pinks/eris
1
0
Fork 0

feat: home page with counters

This commit is contained in:
pinks 2023-10-10 18:21:25 +02:00
parent bf75cce20c
commit 0e88bc5053
9 changed files with 261 additions and 42 deletions

View File

@ -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,
},
}, },
}; };
}, },

View File

@ -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" }),
}, },
}; };
}, },

View File

@ -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");
},
);

View File

@ -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(),
}; };
}, },

View File

@ -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",

View File

@ -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>;
}

View File

@ -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"
/> />

61
ui/Counter.tsx Normal file
View File

@ -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>
);
}

48
ui/HomePage.tsx Normal file
View File

@ -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>
);
}