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 { liveGlobalStats } from "../app/globalStatsStore.ts";
|
||||
import { getDailyStats } from "../app/dailyStatsStore.ts";
|
||||
import { getUserStats } from "../app/userStatsStore.ts";
|
||||
import { getUserDailyStats } from "../app/userDailyStatsStore.ts";
|
||||
|
||||
export const statsRoute = createPathFilter({
|
||||
"global": createMethodFilter({
|
||||
"": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const stats = liveGlobalStats;
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
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 month = Number(params.month);
|
||||
const day = Number(params.day);
|
||||
const minDate = new Date("2023-01-01");
|
||||
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" },
|
||||
};
|
||||
}
|
||||
const stats = await getDailyStats(year, month, day);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
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 { getConfig } from "../app/config.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
|
@ -12,22 +11,41 @@ export const usersRoute = createPathFilter({
|
|||
if (chat.type !== "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 isAdmin = config?.adminUsernames?.includes(chat.username);
|
||||
const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
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" },
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
tagsCount: {
|
||||
tagCountMap: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "number" },
|
||||
},
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["userId", "imageCount", "pixelCount", "tagsCount", "timestamp"],
|
||||
required: ["userId", "imageCount", "pixelCount", "tagCountMap", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export type UserStats = jsonType<typeof userStatsSchema>;
|
||||
|
@ -49,7 +49,7 @@ export const getUserStats = kvMemoize(
|
|||
async (userId: number): Promise<UserStats> => {
|
||||
let imageCount = 0;
|
||||
let pixelCount = 0;
|
||||
const tagsCount: Record<string, number> = {};
|
||||
const tagCountMap: Record<string, number> = {};
|
||||
|
||||
logger().info(`Calculating user stats for ${userId}`);
|
||||
|
||||
|
@ -58,15 +58,27 @@ export const getUserStats = kvMemoize(
|
|||
) {
|
||||
imageCount++;
|
||||
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())
|
||||
// remove empty tags
|
||||
.filter((tag) => tag.length > 0)
|
||||
.map((tag) => tag.toLowerCase())
|
||||
.map((tag) => tag.replace(/[()[\]]/, ""))
|
||||
.map((tag) => tag.replace(/:[\d.]/g, ""))
|
||||
.map((tag) => tag.replace(/ +/g, " ")) ?? [];
|
||||
// lowercase tags
|
||||
.map((tag) => tag.toLowerCase()) ??
|
||||
// default to empty array
|
||||
[];
|
||||
|
||||
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,
|
||||
imageCount,
|
||||
pixelCount,
|
||||
tagsCount,
|
||||
tagCountMap,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -36,9 +36,9 @@
|
|||
"png_chunks_extract": "https://esm.sh/png-chunks-extract@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",
|
||||
"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-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",
|
||||
|
|
|
@ -5,6 +5,7 @@ import { AppHeader } from "./AppHeader.tsx";
|
|||
import { QueuePage } from "./QueuePage.tsx";
|
||||
import { SettingsPage } from "./SettingsPage.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { HomePage } from "./HomePage.tsx";
|
||||
|
||||
export function App() {
|
||||
// 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),
|
||||
);
|
||||
|
||||
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 (
|
||||
<header
|
||||
className={cx(
|
||||
|
@ -61,10 +70,10 @@ export function AppHeader(
|
|||
|
||||
{/* user avatar */}
|
||||
{user.data
|
||||
? user.data.photoData
|
||||
? userPhoto.data
|
||||
? (
|
||||
<img
|
||||
src={`data:image/jpeg;base64,${user.data.photoData}`}
|
||||
src={userPhoto.data}
|
||||
alt="avatar"
|
||||
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