diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts
new file mode 100644
index 00000000..ef917ee1
--- /dev/null
+++ b/packages/skin-database/app/(modern)/scroll/Events.ts
@@ -0,0 +1,65 @@
+"use server";
+
+import { knex } from "../../../db";
+
+export async function logUserEvent(sessionId: string, event: UserEvent) {
+ const timestamp = Date.now();
+
+ await knex("user_log_events").insert({
+ session_id: sessionId,
+ timestamp: timestamp,
+ metadata: JSON.stringify(event),
+ });
+}
+
+type UserEvent =
+ | {
+ type: "session_start";
+ }
+ | {
+ type: "session_end";
+ reason: "unmount" | "before_unload";
+ }
+ | {
+ type: "skin_view_start";
+ skinMd5: string;
+ }
+ | {
+ type: "skin_view_end";
+ skinMd5: string;
+ durationMs: number;
+ }
+ | {
+ type: "skins_fetch_start";
+ offset: number;
+ }
+ | {
+ type: "skins_fetch_success";
+ offset: number;
+ }
+ | {
+ type: "skins_fetch_failure";
+ offset: number;
+ errorMessage: string;
+ }
+ | {
+ type: "readme_expand";
+ skinMd5: string;
+ }
+ | {
+ type: "skin_download";
+ skinMd5: string;
+ }
+ | {
+ type: "share_open";
+ skinMd5: string;
+ }
+ | {
+ type: "share_success";
+ skinMd5: string;
+ }
+ | {
+ type: "share_failure";
+ skinMd5: string;
+ errorMessage: string;
+ };
diff --git a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx
new file mode 100644
index 00000000..f567c280
--- /dev/null
+++ b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { ClientSkin } from "./SkinScroller";
+
+type Props = {
+ skin: ClientSkin;
+ index: number;
+ sessionId: string;
+};
+
+export default function SkinPage({ skin, index }: Props) {
+ return (
+
+

+
+
+ {skin.fileName}
+
+
+ {skin.readmeStart}
+
+
+
+ );
+}
diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
index aff10b26..3d3d467b 100644
--- a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
+++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useState, useLayoutEffect } from "react";
+import { useState, useLayoutEffect, useEffect } from "react";
+import SkinPage from "./SkinPage";
+import { logUserEvent } from "./Events";
export type ClientSkin = {
screenshotUrl: string;
@@ -11,10 +13,15 @@ export type ClientSkin = {
type Props = {
initialSkins: ClientSkin[];
- getSkins: (offset: number) => Promise;
+ getSkins: (sessionId: string, offset: number) => Promise;
+ sessionId: string;
};
-export default function SkinScroller({ initialSkins, getSkins }: Props) {
+export default function SkinScroller({
+ initialSkins,
+ getSkins,
+ sessionId,
+}: Props) {
const [skins, setSkins] = useState(initialSkins);
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false);
@@ -38,16 +45,72 @@ export default function SkinScroller({ initialSkins, getSkins }: Props) {
};
}, [containerRef]);
+ useEffect(() => {
+ logUserEvent(sessionId, {
+ type: "session_start",
+ });
+
+ function beforeUnload() {
+ logUserEvent(sessionId, {
+ type: "session_end",
+ reason: "before_unload",
+ });
+ }
+
+ addEventListener("beforeunload", beforeUnload);
+ return () => {
+ removeEventListener("beforeunload", beforeUnload);
+ logUserEvent(sessionId, {
+ type: "session_end",
+ reason: "unmount",
+ });
+ };
+ }, []);
+
+ useEffect(() => {
+ logUserEvent(sessionId, {
+ type: "skin_view_start",
+ skinMd5: skins[visibleSkinIndex].md5,
+ });
+ const startTime = Date.now();
+ return () => {
+ const durationMs = Date.now() - startTime;
+ logUserEvent(sessionId, {
+ type: "skin_view_end",
+ skinMd5: skins[visibleSkinIndex].md5,
+ durationMs,
+ });
+ };
+ }, [visibleSkinIndex, skins, fetching]);
+
useLayoutEffect(() => {
if (fetching) {
return;
}
if (visibleSkinIndex + 5 >= skins.length) {
setFetching(true);
- getSkins(skins.length).then((newSkins) => {
- setSkins([...skins, ...newSkins]);
- setFetching(false);
+ console.log("Fetching more skins...");
+ logUserEvent(sessionId, {
+ type: "skins_fetch_start",
+ offset: skins.length,
});
+ getSkins(sessionId, skins.length)
+ .then((newSkins) => {
+ logUserEvent(sessionId, {
+ type: "skins_fetch_success",
+ offset: skins.length,
+ });
+ setSkins([...skins, ...newSkins]);
+ setFetching(false);
+ })
+ .catch((error) => {
+ logUserEvent(sessionId, {
+ type: "skins_fetch_failure",
+ offset: skins.length,
+ errorMessage: error.message,
+ });
+ setFetching(false);
+ });
}
}, [visibleSkinIndex, skins, fetching]);
@@ -62,64 +125,12 @@ export default function SkinScroller({ initialSkins, getSkins }: Props) {
>
{skins.map((skin, i) => {
return (
-
-

-
-
- {skin.fileName}
-
-
- {skin.readmeStart}
-
-
-
+ skin={skin}
+ index={i}
+ sessionId={sessionId}
+ />
);
})}
diff --git a/packages/skin-database/app/(modern)/scroll/page.tsx b/packages/skin-database/app/(modern)/scroll/page.tsx
index 5bc0c14f..af52bf08 100644
--- a/packages/skin-database/app/(modern)/scroll/page.tsx
+++ b/packages/skin-database/app/(modern)/scroll/page.tsx
@@ -1,40 +1,49 @@
-import ClassicSkinResolver from "../../../api/graphql/resolvers/ClassicSkinResolver";
-import SkinsConnection from "../../../api/graphql/SkinsConnection";
import UserContext from "../../../data/UserContext";
+import SessionModel from "../../../data/SessionModel";
import "./scroll.css";
import SkinScroller, { ClientSkin } from "./SkinScroller";
+import { getScrollPage } from "../../../data/skins";
+import SkinModel from "../../../data/SkinModel";
-async function getClientSkins(offset: number): Promise {
+async function getClientSkins(sessionId: string): Promise {
"use server";
const ctx = new UserContext();
- const connection = new SkinsConnection(10, offset, "MUSEUM", null);
- const skins = await connection.nodes(ctx);
- if (skins == null) {
- return [];
- }
- const classicSkins = skins.filter(
- (skin): skin is ClassicSkinResolver => skin instanceof ClassicSkinResolver
- );
- const clientSkins: ClientSkin[] = await Promise.all(
- classicSkins.map(async (skin) => {
- const url = await skin.screenshot_url();
- const readmeText = await skin.readme_text();
+
+ const page = await getScrollPage(sessionId);
+
+ const skins = await Promise.all(
+ page.map(async (item) => {
+ const model = await SkinModel.fromMd5Assert(ctx, item.md5);
+ const readmeText = await model.getReadme();
return {
- screenshotUrl: url,
- md5: skin.md5(),
- fileName: await skin.filename(true),
+ screenshotUrl: model.getScreenshotUrl(),
+ md5: item.md5,
+ // TODO: Normalize to .wsz
+ fileName: await model.getFileName(),
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
};
})
);
- return clientSkins;
+ for (const skin of skins) {
+ SessionModel.addSkin(sessionId, skin.md5);
+ }
+ return skins;
}
/**
* A tik-tok style scroll page where we display one skin at a time in full screen
*/
export default async function ScrollPage() {
- const initialSkins = await getClientSkins(0);
+ // Create the session in the database
+ const sessionId = await SessionModel.create();
- return ;
+ const initialSkins = await getClientSkins(sessionId);
+
+ return (
+
+ );
}
diff --git a/packages/skin-database/data/SessionModel.ts b/packages/skin-database/data/SessionModel.ts
new file mode 100644
index 00000000..b6d3c045
--- /dev/null
+++ b/packages/skin-database/data/SessionModel.ts
@@ -0,0 +1,29 @@
+import { knex } from "../db";
+import { randomUUID } from "crypto";
+
+export default class SessionModel {
+ static async create(): Promise {
+ const sessionId = randomUUID();
+ const startTime = Date.now();
+ await knex("session").insert({
+ id: sessionId,
+ start_time: startTime,
+ });
+ return sessionId;
+ }
+
+ static async addSkin(sessionId: string, skinMd5: string): Promise {
+ await knex("session_skin").insert({
+ session_id: sessionId,
+ skin_md5: skinMd5,
+ });
+ }
+
+ static async getIncludedSkinCount(sessionId: string): Promise {
+ const result = await knex("session_skin")
+ .where({ session_id: sessionId })
+ .count("* as count")
+ .first();
+ return result ? (result.count as number) : 0;
+ }
+}
diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts
index 6d48f701..7fef938c 100644
--- a/packages/skin-database/data/skins.ts
+++ b/packages/skin-database/data/skins.ts
@@ -718,6 +718,53 @@ export type MuseumPage = Array<{
nsfw: boolean;
}>;
+export type ScrollPage = Array<{
+ md5: string;
+}>;
+const PAGE_SIZE = 50;
+const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
+
+const randomCount = Math.floor(PAGE_SIZE * FRESHNESS_PERCENTAGE);
+const curatedCount = PAGE_SIZE - randomCount;
+
+export async function getScrollPage(sessionId: string): Promise {
+ const skins = await knex.raw(
+ `
+SELECT
+ museum_sort_order.skin_md5
+FROM
+ museum_sort_order
+WHERE museum_sort_order.skin_md5 NOT IN (SELECT skin_md5 from session_skin WHERE session_id = ?)
+LIMIT ?`,
+ [sessionId, curatedCount]
+ );
+
+ const randomSkins = await knex.raw(
+ `
+SELECT
+ skins.md5 as skin_md5
+FROM
+ skins
+LEFT JOIN session_skin ON session_skin.skin_md5 = skins.md5 AND session_skin.session_id = ?
+LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = 'NSFW'
+WHERE
+ skins.skin_type = 1 AND session_skin.skin_md5 IS NULL AND skin_reviews.skin_md5 IS NULL
+ORDER BY RANDOM()
+LIMIT ?`,
+ [sessionId, randomCount]
+ );
+
+ // Note: Technically we could get duplicates if a random skin is also in museum_sort_order,
+ // but in practice this is rare and acceptable for the use case.
+ const allSkins = skins.concat(randomSkins);
+ // Shuffle the results
+ allSkins.sort(() => Math.random() - 0.5);
+
+ return allSkins.map(({ skin_md5 }) => {
+ return { md5: skin_md5 };
+ });
+}
+
export async function getMuseumPage({
offset,
first,
diff --git a/packages/skin-database/legacy-client/src/redux/selectors.js b/packages/skin-database/legacy-client/src/redux/selectors.js
index 33bdfdd1..83b8ff6c 100644
--- a/packages/skin-database/legacy-client/src/redux/selectors.js
+++ b/packages/skin-database/legacy-client/src/redux/selectors.js
@@ -165,6 +165,12 @@ export const getRouteData = createSelector(
getPermalinkUrlFromHash,
skinData
) => {
+ const currentUrl = window.location.href;
+ // Hard code ignore "modern" paths for now.
+ if (currentUrl.includes("/scroll")) {
+ return { url: "/scroll/", title: "Scroll" };
+ }
+
if (activeContentPage === REVIEW_PAGE) {
return { url: "/review/", title: "Review" };
}
diff --git a/packages/skin-database/migrations/20251106000000_user_log_events.ts b/packages/skin-database/migrations/20251106000000_user_log_events.ts
new file mode 100644
index 00000000..1f35cc39
--- /dev/null
+++ b/packages/skin-database/migrations/20251106000000_user_log_events.ts
@@ -0,0 +1,21 @@
+import * as Knex from "knex";
+
+export async function up(knex: Knex): Promise {
+ await knex.raw(`
+ CREATE TABLE user_log_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ timestamp INTEGER NOT NULL,
+ metadata TEXT NOT NULL
+ );
+
+ CREATE INDEX idx_session_id ON user_log_events(session_id);
+ CREATE INDEX idx_timestamp ON user_log_events(timestamp);
+ `);
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw(`
+ DROP TABLE IF EXISTS user_log_events;
+ `);
+}
diff --git a/packages/skin-database/migrations/20251107000000_session_tables.ts b/packages/skin-database/migrations/20251107000000_session_tables.ts
new file mode 100644
index 00000000..dc212648
--- /dev/null
+++ b/packages/skin-database/migrations/20251107000000_session_tables.ts
@@ -0,0 +1,36 @@
+import * as Knex from "knex";
+
+export async function up(knex: Knex): Promise {
+ await knex.raw(`
+ CREATE TABLE session (
+ id TEXT PRIMARY KEY,
+ start_time INTEGER NOT NULL
+ );
+ `);
+
+ await knex.raw(`
+ CREATE TABLE session_skin (
+ session_id TEXT NOT NULL,
+ skin_md5 TEXT NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES session(id)
+ );
+ `);
+
+ await knex.raw(`
+ CREATE INDEX idx_session_skin_session_id ON session_skin(session_id);
+ `);
+
+ await knex.raw(`
+ CREATE INDEX idx_session_skin_skin_md5 ON session_skin(skin_md5);
+ `);
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw(`
+ DROP TABLE IF EXISTS session_skin;
+ `);
+
+ await knex.raw(`
+ DROP TABLE IF EXISTS session;
+ `);
+}