mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-17 16:46:04 +00:00
Make scroll sessions more dynamic
This commit is contained in:
parent
fbe3a0090f
commit
52ff84d29b
9 changed files with 380 additions and 84 deletions
65
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
65
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
72
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
72
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
key={skin.md5}
|
||||
skin-md5={skin.md5}
|
||||
skin-index={index}
|
||||
className="scroller"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
paddingTop: "4rem",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
flexGrow: 1,
|
||||
paddingLeft: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
fontSize: "0.9rem",
|
||||
paddingBottom: "0",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
color: "#ccc",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{skin.fileName}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
paddingTop: "0",
|
||||
color: "#999",
|
||||
fontFamily: 'monospace, "Courier New", Courier, monospace',
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{skin.readmeStart}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ClientSkin[]>;
|
||||
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function SkinScroller({ initialSkins, getSkins }: Props) {
|
||||
export default function SkinScroller({
|
||||
initialSkins,
|
||||
getSkins,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [skins, setSkins] = useState<ClientSkin[]>(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 (
|
||||
<div
|
||||
<SkinPage
|
||||
key={skin.md5}
|
||||
skin-md5={skin.md5}
|
||||
skin-index={i}
|
||||
className="scroller"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
paddingTop: "4rem",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
flexGrow: 1,
|
||||
paddingLeft: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
fontSize: "0.9rem",
|
||||
paddingBottom: "0",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
color: "#ccc",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{skin.fileName}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
paddingTop: "0",
|
||||
color: "#999",
|
||||
fontFamily: 'monospace, "Courier New", Courier, monospace',
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{skin.readmeStart}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
skin={skin}
|
||||
index={i}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ClientSkin[]> {
|
||||
async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
|
||||
"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 <SkinScroller initialSkins={initialSkins} getSkins={getClientSkins} />;
|
||||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={initialSkins}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
packages/skin-database/data/SessionModel.ts
Normal file
29
packages/skin-database/data/SessionModel.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { knex } from "../db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default class SessionModel {
|
||||
static async create(): Promise<string> {
|
||||
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<void> {
|
||||
await knex("session_skin").insert({
|
||||
session_id: sessionId,
|
||||
skin_md5: skinMd5,
|
||||
});
|
||||
}
|
||||
|
||||
static async getIncludedSkinCount(sessionId: string): Promise<number> {
|
||||
const result = await knex("session_skin")
|
||||
.where({ session_id: sessionId })
|
||||
.count("* as count")
|
||||
.first();
|
||||
return result ? (result.count as number) : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScrollPage> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import * as Knex from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS user_log_events;
|
||||
`);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import * as Knex from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS session_skin;
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS session;
|
||||
`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue