Compare commits

..

No commits in common. "master" and "2.2.0" have entirely different histories.

148 changed files with 797 additions and 8201 deletions

100
.eslintrc
View file

@ -4,11 +4,15 @@
"jsx": true,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
"jsx": true,
"experimentalObjectRestSpread": true
}
},
"plugins": ["prettier", "@typescript-eslint"],
"plugins": ["prettier"],
"settings": {
"react": {
"version": "15.2"
},
"import/resolver": {
"node": {
"extensions": [".js", ".ts", ".tsx"]
@ -17,85 +21,27 @@
},
"env": {
"node": true,
"amd": true,
"es6": true,
"jest": true
},
"globals": {
"window": true,
"document": true,
"console": true,
"navigator": true,
"alert": true,
"Blob": true,
"fetch": true,
"FileReader": true,
"Element": true,
"AudioNode": true,
"MutationObserver": true,
"Image": true,
"location": true
},
"rules": {
"prettier/prettier": "error",
"no-constant-binary-expression": "error",
"array-callback-return": "error",
"no-template-curly-in-string": "error",
"no-promise-executor-return": "error",
"no-constructor-return": "error",
"no-unsafe-optional-chaining": "error",
"block-scoped-var": "warn",
"camelcase": "error",
"constructor-super": "error",
"dot-notation": "error",
"eqeqeq": ["error", "smart"],
"guard-for-in": "error",
"max-depth": ["warn", 4],
"new-cap": "error",
"no-caller": "error",
"no-const-assign": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-div-regex": "warn",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty-character-class": "error",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "warn",
"no-extra-boolean-cast": "error",
"no-extra-semi": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-implied-eval": "error",
"no-inner-declarations": "error",
"no-irregular-whitespace": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-multi-str": "error",
"no-nested-ternary": "warn",
"no-new-object": "error",
"no-new-symbol": "error",
"no-new-wrappers": "error",
"no-obj-calls": "error",
"no-octal": "error",
"no-octal-escape": "error",
"no-proto": "error",
"no-redeclare": "error",
"no-shadow": "warn",
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unused-expressions": "error",
"no-useless-rename": "error",
"no-var": "error",
"no-with": "error",
"prefer-arrow-callback": "warn",
"prefer-const": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"radix": "error",
"use-isnan": "error",
"valid-typeof": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
"no-constant-binary-expression": "error"
}
}

View file

@ -50,9 +50,6 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
needs: [ci]
permissions:
contents: read
id-token: write # Required for OIDC trusted publishing
steps:
- uses: actions/checkout@v4
- name: Install pnpm
@ -64,8 +61,6 @@ jobs:
node-version: 20.x
registry-url: https://registry.npmjs.org/
cache: "pnpm"
- name: Update npm to latest version
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore build artifacts
@ -86,40 +81,31 @@ jobs:
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
env:
RELEASE_COMMIT_SHA: ${{ github.sha }}
- name: Set version for tagged release
- name: Build release version
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Setting version to $VERSION for tagged release"
cd packages/webamp && npm version $VERSION --no-git-tag-version
cd ../ani-cursor && npm version $VERSION --no-git-tag-version
cd ../winamp-eqf && npm version $VERSION --no-git-tag-version
# TODO: Update version number in webampLazy.tsx if needed
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
- name: Publish ani-cursor to npm
working-directory: ./packages/ani-cursor
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish winamp-eqf to npm
working-directory: ./packages/winamp-eqf
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish webamp to npm
working-directory: ./packages/webamp
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
# Use pre-built artifacts instead of rebuilding
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
node_modules
.vscode
.idea
dist
# Turborepo cache

View file

@ -20,9 +20,9 @@ Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in
- [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
- [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
- [`packages/skin-museum-client`](https://github.com/captbaritone/webamp/tree/master/packages/skin-museum-client): The front-end component of https://skins.webamp.org.
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
- [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
- [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module

6
deploy.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
pnpm --filter ani-cursor build
pnpm --filter webamp build
pnpm --filter webamp build-library
pnpm --filter webamp-modern build
mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern

View file

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,5 +0,0 @@
# `webamp/lazy` Example
Shows how it's possible to use Webamp with lazy loading and TypeScript. Uses [Vite](https://vitejs.dev/) for development and bundling.
Pay special attention to the versions used in `package.json` since some beta versions are required for this to work.

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webamp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,22 +0,0 @@
{
"name": "lazy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.0.4"
},
"dependencies": {
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"jszip": "^3.10.1",
"music-metadata": "^11.6.0",
"webamp": "^2.2.0"
}
}

View file

@ -1,57 +0,0 @@
import Webamp from "webamp/lazy";
const webamp = new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
windowLayout: {
main: { position: { left: 0, top: 0 } },
equalizer: { position: { left: 0, top: 116 } },
playlist: {
position: { left: 0, top: 232 },
size: { extraHeight: 4, extraWidth: 0 },
},
milkdrop: {
position: { left: 275, top: 0 },
size: { extraHeight: 12, extraWidth: 7 },
},
},
requireJSZip: async () => {
const JSZip = await import("jszip");
return JSZip.default;
},
// @ts-ignore
requireMusicMetadata: async () => {
return await import("music-metadata");
},
__butterchurnOptions: {
// @ts-ignore
importButterchurn: () => import("butterchurn"),
// @ts-ignore
getPresets: async () => {
const butterchurnPresets = await import(
// @ts-ignore
"butterchurn-presets/dist/base.js"
);
// Convert the presets object
return Object.entries(butterchurnPresets.default).map(
([name, preset]) => {
return { name, butterchurnPresetObject: preset };
}
);
},
butterchurnOpen: true,
},
});
webamp.renderWhenReady(document.getElementById("app")!);

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -10,7 +10,7 @@
<!-- Webamp will attempt to center itself within this div -->
</div>
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
import Webamp from "https://unpkg.com/webamp@^2";
const webamp = new Webamp({
initialTracks: [
{

View file

@ -11,11 +11,12 @@
</div>
<script type="module">
/**
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
* entrypoint which includes the Butterchurn library to enable the
* Milkdrop visualizer.
* Webamp includes a `webamp/butterchurn` entrypoint which includes the Butterchurn
* library to enable the Milkdrop visualizer. It hasn't been included in a
* stable version yet, until it is, you can use by installing
* `webamp@next`.
*/
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
import Webamp from "https://unpkg.com/webamp@0.0.0-next-7cdc35b/butterchurn";
const webamp = new Webamp({
initialTracks: [
{

View file

@ -1,13 +0,0 @@
# Multiple Milkdrop Presets Example
An example of overriding the default Milkdrop presets with a custom set of presets.
This example fetches the Webamp bundle from a free CDN, and fetches the audio file and skin from a free CDN as well.
You should be able to open this local html file in your browser and see Webamp working.
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp
$ open examples/multipleMilkdropPresets/index.html
```

View file

@ -1,45 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script type="module">
// The `requireButterchurnPresets` config option is not yet available in a stable release.
// for now you need to install a pre-release version of Webamp to use it.
import Webamp from "https://unpkg.com/webamp@0.0.0-next-41cfbbb/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
requireButterchurnPresets() {
return [
{
name: "md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.milk",
butterchurnPresetUrl:
"https://archive.org/cors/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.json",
},
];
},
});
// Returns a promise indicating when it's done loading.
webamp.renderWhenReady(document.getElementById("app"));
</script>
</body>
</html>

View file

@ -1,5 +1,5 @@
[build]
command = "pnpm run deploy"
command = "pnpm deploy"
publish = "packages/webamp/dist/demo-site/"
# A short URL for listeners of https://changelog.com/podcast/291

View file

@ -13,9 +13,9 @@
"test:integration": "npx turbo run integration-tests",
"test:all": "npx turbo run test integration-tests",
"test:unit": "jest",
"lint": "npx turbo lint",
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check",
"deploy": "npx turbo webamp#build webamp-modern#build --concurrency 1 && mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern",
"deploy": "sh deploy.sh",
"format": "prettier --write '**/*.{js,ts,tsx}'"
},
"devDependencies": {
@ -43,10 +43,5 @@
"prettier": {
"trailingComma": "es5"
},
"version": "0.0.0-next-87012d8d",
"pnpm": {
"patchedDependencies": {
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
}
}
"version": "0.0.0-next-87012d8d"
}

View file

@ -32,7 +32,6 @@
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext ts,js",
"test": "jest",
"prepublish": "tsc"
},

View file

@ -1,28 +1,32 @@
module.exports = {
extends: ["plugin:@typescript-eslint/recommended"],
env: {
node: true,
es2021: true,
jest: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 12,
sourceType: "module",
},
plugins: ["@typescript-eslint"],
rules: {
// Disable rules that conflict with the project's style
// "no-console": "warn",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-require-imports": "off", // Allow require() in JS files
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-namespace": "off",
// Override the base no-shadow rule since it conflicts with TypeScript
"no-shadow": "off",
// Relax rules for this project's existing style
camelcase: "off",
"dot-notation": "off",
eqeqeq: "off",
"no-undef-init": "off",
"no-return-await": "off",
"prefer-arrow-callback": "off",
"no-div-regex": "off",
"guard-for-in": "off",
"prefer-template": "off",
"no-else-return": "off",
"prefer-const": "off",
"new-cap": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
ignorePatterns: ["dist/**"],
};

View file

@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
await setHashesForSkin(skin);
// Disable while we figure out our quota
await Skins.updateSearchIndex(ctx, md5);
// await Skins.updateSearchIndex(ctx, md5);
return { md5, status: "ADDED", skinType: "CLASSIC" };
}

View file

@ -1,3 +1,8 @@
import { Router } from "express";
import { createYoga, YogaInitialContext } from "graphql-yoga";
// import DEFAULT_QUERY from "./defaultQuery";
import { getSchema } from "./schema";
import UserContext from "../../data/UserContext.js";
/** @gqlContext */
@ -7,3 +12,18 @@ export type Ctx = Express.Request;
export function getUserContext(ctx: Ctx): UserContext {
return ctx.ctx;
}
const router = Router();
const yoga = createYoga({
schema: getSchema(),
context: (ctx: YogaInitialContext) => {
// @ts-expect-error
return ctx.req;
},
});
// Bind GraphQL Yoga to the graphql endpoint to avoid rendering the playground on any path
router.use("", yoga);
export default router;

View file

@ -1,96 +0,0 @@
import { Int } from "grats";
import { knex } from "../../../db";
import SkinModel from "../../../data/SkinModel";
import ClassicSkinResolver from "./ClassicSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import UserContext from "../../../data/UserContext";
import { ISkin } from "./CommonSkinResolver";
import { SkinRow } from "../../../types";
/**
* Connection for bulk download skin metadata
* @gqlType
*/
export class BulkDownloadConnection {
_offset: number;
_first: number;
constructor(first: number, offset: number) {
this._first = first;
this._offset = offset;
}
/**
* Total number of skins available for download
* @gqlField
*/
async totalCount(): Promise<Int> {
// Get count of both classic and modern skins
const [classicResult, modernResult] = await Promise.all([
knex("skins").where({ skin_type: 1 }).count("* as count"),
knex("skins").where({ skin_type: 2 }).count("* as count"),
]);
const classicCount = Number(classicResult[0].count);
const modernCount = Number(modernResult[0].count);
return classicCount + modernCount;
}
/**
* Estimated total size in bytes (approximation for progress indication)
* @gqlField
*/
async estimatedSizeBytes(): Promise<string> {
const totalCount = await this.totalCount();
// Rough estimate: average skin is ~56KB
return (totalCount * 56 * 1024).toString();
}
/**
* List of skin metadata for bulk download
* @gqlField
*/
async nodes(ctx: UserContext): Promise<Array<ISkin>> {
// Get skins ordered by skin_type (classic first, then modern) and id for consistency
const skins = await knex<SkinRow>("skins")
.select(["id", "md5", "skin_type", "emails"])
.orderBy([{ column: "skins.id", order: "asc" }])
.where({ skin_type: 1 })
.orWhere({ skin_type: 2 })
.where((builder) => {
builder.where({ skin_type: 1 }).orWhere({ skin_type: 2 });
})
.limit(this._first)
.offset(this._offset);
return skins.map((skinRow) => {
const skinModel = new SkinModel(ctx, skinRow);
if (skinRow.skin_type === 1) {
return new ClassicSkinResolver(skinModel);
} else if (skinRow.skin_type === 2) {
return new ModernSkinResolver(skinModel);
} else {
throw new Error("Expected classic or modern skin");
}
});
}
}
/**
* Get metadata for bulk downloading all skins in the museum
* @gqlQueryField
*/
export function bulkDownload({
first = 1000,
offset = 0,
}: {
first?: Int;
offset?: Int;
}): BulkDownloadConnection {
if (first > 10000) {
throw new Error("Maximum limit is 10000 for bulk download");
}
return new BulkDownloadConnection(first, offset);
}

View file

@ -1,6 +1,7 @@
import { ISkin } from "./CommonSkinResolver";
import { NodeResolver, toId } from "./NodeResolver";
import ReviewResolver from "./ReviewResolver";
import path from "path";
import { ID, Int } from "grats";
import SkinModel from "../../../data/SkinModel";
@ -23,7 +24,11 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
return toId(this.__typename, this.md5());
}
async filename(normalize_extension?: boolean): Promise<string> {
return await this._model.getFileName(normalize_extension);
const filename = await this._model.getFileName();
if (normalize_extension) {
return path.parse(filename).name + ".wsz";
}
return filename;
}
museum_url(): string {
@ -41,7 +46,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
nsfw(): Promise<boolean> {
return this._model.getIsNsfw();
}
average_color(): string | null {
average_color(): string {
return this._model.getAverageColor();
}
/**

View file

@ -69,34 +69,19 @@ export function id(skin: ISkin): ID {
* has been uploaded under multiple names. Here we just pick one.
* @gqlField
*/
export async function filename(
export function filename(
skin: ISkin,
{
normalize_extension = false,
include_museum_id = false,
}: {
/**
* If true, the the correct file extension (.wsz or .wal) will be .
* Otherwise, the original user-uploaded file extension will be used.
*/
normalize_extension?: boolean;
/**
* If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
*/
include_museum_id?: boolean;
}
): Promise<string> {
const baseFilename = await skin.filename(normalize_extension);
if (!include_museum_id) {
return baseFilename;
}
const museumId = skin._model.getId();
const segments = baseFilename.split(".");
const fileExtension = segments.pop();
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
return skin.filename(normalize_extension);
}
/**

View file

@ -1,6 +1,5 @@
# Schema generated by Grats (https://grats.capt.dev)
# Do not manually edit. Regenerate by running `npx grats`.
"""
Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
In all other cases, the position is non-null.
@ -93,10 +92,6 @@ interface Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -169,16 +164,6 @@ type ArchiveFile {
url: String
}
"""Connection for bulk download skin metadata"""
type BulkDownloadConnection {
"""Estimated total size in bytes (approximation for progress indication)"""
estimatedSizeBytes: String @semanticNonNull
"""List of skin metadata for bulk download"""
nodes: [Skin!] @semanticNonNull
"""Total number of skins available for download"""
totalCount: Int @semanticNonNull
}
"""A classic Winamp skin"""
type ClassicSkin implements Node & Skin {
"""List of files contained within the skin's .wsz archive"""
@ -192,10 +177,6 @@ type ClassicSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -326,10 +307,6 @@ type ModernSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -407,8 +384,6 @@ type Mutation {
}
type Query {
"""Get metadata for bulk downloading all skins in the museum"""
bulkDownload(first: Int! = 1000, offset: Int! = 0): BulkDownloadConnection @semanticNonNull
"""
Fetch archive file by it's MD5 hash

View file

@ -2,10 +2,8 @@
* Executable schema generated by Grats (https://grats.capt.dev)
* Do not manually edit. Regenerate by running `npx grats`.
*/
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
import { getUserContext } from "./index";
import { bulkDownload as queryBulkDownloadResolver } from "./resolvers/BulkDownloadConnection";
import { defaultFieldResolver, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLBoolean, GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNonNull, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
import { getUserContext as getUserContext } from "./index";
import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./../../data/ArchiveFileModel";
import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./../../data/IaItemModel";
import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver, search_classic_skins as querySearch_classic_skinsResolver, search_skins as querySearch_skinsResolver, skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver";
@ -28,75 +26,6 @@ async function assertNonNull<T>(value: T | Promise<T>): Promise<T> {
return awaited;
}
export function getSchema(): GraphQLSchema {
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
fields() {
return {
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getIsoDate());
}
},
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
}
},
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
type: GraphQLInt,
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
}
}
};
}
});
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
name: "InternetArchiveItem",
fields() {
@ -258,13 +187,9 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -328,33 +253,70 @@ export function getSchema(): GraphQLSchema {
};
}
});
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
name: "BulkDownloadConnection",
description: "Connection for bulk download skin metadata",
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
fields() {
return {
estimatedSizeBytes: {
description: "Estimated total size in bytes (approximation for progress indication)",
name: "estimatedSizeBytes",
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
type: GraphQLString,
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
resolve(source) {
return assertNonNull(source.getIsoDate());
}
},
nodes: {
description: "List of skin metadata for bulk download",
name: "nodes",
type: new GraphQLList(new GraphQLNonNull(SkinType)),
resolve(source, _args, context) {
return assertNonNull(source.nodes(getUserContext(context)));
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
}
},
totalCount: {
description: "Total number of skins available for download",
name: "totalCount",
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
type: GraphQLInt,
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
}
}
};
@ -420,13 +382,9 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -587,13 +545,9 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -949,30 +903,13 @@ export function getSchema(): GraphQLSchema {
name: "Query",
fields() {
return {
bulkDownload: {
description: "Get metadata for bulk downloading all skins in the museum",
name: "bulkDownload",
type: BulkDownloadConnectionType,
args: {
first: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 1000
},
offset: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
}
},
resolve(_source, args) {
return assertNonNull(queryBulkDownloadResolver(args));
}
},
fetch_archive_file_by_md5: {
description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.",
name: "fetch_archive_file_by_md5",
type: ArchiveFileType,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -986,6 +923,7 @@ export function getSchema(): GraphQLSchema {
type: InternetArchiveItemType,
args: {
identifier: {
name: "identifier",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -999,6 +937,7 @@ export function getSchema(): GraphQLSchema {
type: SkinType,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1012,6 +951,7 @@ export function getSchema(): GraphQLSchema {
type: TweetType,
args: {
url: {
name: "url",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1033,10 +973,12 @@ export function getSchema(): GraphQLSchema {
type: ModernSkinsConnectionType,
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
}
@ -1051,6 +993,7 @@ export function getSchema(): GraphQLSchema {
type: NodeType,
args: {
id: {
name: "id",
type: new GraphQLNonNull(GraphQLID)
}
},
@ -1064,14 +1007,17 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(ClassicSkinType),
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
query: {
name: "query",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1085,14 +1031,17 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinType),
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
query: {
name: "query",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1114,17 +1063,21 @@ export function getSchema(): GraphQLSchema {
type: SkinsConnectionType,
args: {
filter: {
name: "filter",
type: SkinsFilterOptionType
},
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
sort: {
name: "sort",
type: SkinsSortOptionType
}
},
@ -1146,14 +1099,17 @@ export function getSchema(): GraphQLSchema {
type: TweetsConnectionType,
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
sort: {
name: "sort",
type: TweetsSortOptionType
}
},
@ -1167,6 +1123,7 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinUploadType),
args: {
ids: {
name: "ids",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
}
},
@ -1181,6 +1138,7 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinUploadType),
args: {
md5s: {
name: "md5s",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
}
},
@ -1247,6 +1205,7 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(UploadUrlType),
args: {
files: {
name: "files",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
}
},
@ -1260,9 +1219,11 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
id: {
name: "id",
type: new GraphQLNonNull(GraphQLString)
},
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1283,6 +1244,7 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1296,6 +1258,7 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1309,6 +1272,7 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1322,6 +1286,7 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1335,12 +1300,15 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
email: {
name: "email",
type: GraphQLString
},
message: {
name: "message",
type: new GraphQLNonNull(GraphQLString)
},
url: {
name: "url",
type: GraphQLString
}
},
@ -1360,19 +1328,8 @@ export function getSchema(): GraphQLSchema {
}
});
return new GraphQLSchema({
directives: [...specifiedDirectives, new GraphQLDirective({
name: "semanticNonNull",
locations: [DirectiveLocation.FIELD_DEFINITION],
description: "Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.\nIn all other cases, the position is non-null.\n\nTools doing code generation may use this information to generate the position as non-null if field errors are handled out of band:\n\n```graphql\ntype User {\n # email is semantically non-null and can be generated as non-null by error-handling clients.\n email: String @semanticNonNull\n}\n```\n\nThe `levels` argument indicates what levels are semantically non null in case of lists:\n\n```graphql\ntype User {\n # friends is semantically non null\n friends: [User] @semanticNonNull # same as @semanticNonNull(levels: [0])\n\n # every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [1])\n\n # friends as well as every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [0, 1])\n}\n```\n\n`levels` are zero indexed.\nPassing a negative level or a level greater than the list dimension is an error.",
args: {
levels: {
type: new GraphQLList(GraphQLInt),
defaultValue: [0]
}
}
})],
query: QueryType,
mutation: MutationType,
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, BulkDownloadConnectionType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
});
}

View file

@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60;
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
return Promise.race([
p,
new Promise<never>((_resolve, reject) => {
setTimeout(() => reject("timeout"), duration);
}),
new Promise<never>((resolve, reject) =>
setTimeout(() => reject("timeout"), duration)
),
]);
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return null;
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return null;
}

View file

@ -1,5 +0,0 @@
import App from "../App";
export default function Layout() {
return <App />;
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return null;
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return null;
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return null;
}

View file

@ -1,330 +0,0 @@
"use client";
import {
Smartphone,
Info,
Grid3x3,
Menu,
MessageSquare,
Upload,
Github,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
import {
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useState,
useEffect,
useRef,
} from "react";
export default function BottomMenuBar() {
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const toggleHamburger = () => {
setIsHamburgerOpen(!isHamburgerOpen);
};
// Close hamburger menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
isHamburgerOpen
) {
setIsHamburgerOpen(false);
}
}
if (isHamburgerOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isHamburgerOpen]);
return (
<>
{/* Hamburger Menu Overlay */}
{isHamburgerOpen && (
<div
style={{
position: "fixed",
bottom: "4.5rem",
left: "50%",
transform: "translateX(-50%)",
width: "100%",
maxWidth: MOBILE_MAX_WIDTH,
backgroundColor: "rgba(26, 26, 26, 0.98)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderBottom: "none",
boxShadow: "0 -4px 12px rgba(0, 0, 0, 0.3)",
zIndex: 999,
}}
>
<div ref={menuRef}>
<HamburgerMenuItem
href="/scroll/about"
icon={<Info size={20} />}
label="About"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="/scroll/feedback"
icon={<MessageSquare size={20} />}
label="Feedback"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/"
icon={<Github size={20} />}
label="GitHub"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
</div>
</div>
)}
{/* Bottom Menu Bar */}
<div
style={{
position: "sticky",
bottom: 0,
left: 0,
width: "100%",
backgroundColor: "rgba(26, 26, 26, 0.95)",
backdropFilter: "blur(10px)",
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
padding: "0.75rem 0",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
>
<div
style={{
width: "100%",
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
display: "flex",
justifyContent: "space-evenly",
alignItems: "center",
}}
>
<MenuButton
href="/scroll"
icon={<Grid3x3 size={24} />}
label="Grid"
isActive={pathname === "/scroll"}
/>
<MenuButton
href="/scroll/skin"
icon={<Smartphone size={24} />}
label="Feed"
isActive={pathname.startsWith("/scroll/skin")}
/>
<MenuButton
href="/upload"
icon={<Upload size={24} />}
label="Upload"
isActive={pathname === "/upload"}
/>
<MenuButton
icon={<Menu size={24} />}
label="Menu"
onClick={toggleHamburger}
isButton
isActive={false}
/>
</div>
</div>
</>
);
}
type MenuButtonProps = {
href?: string;
icon: React.ReactNode;
label: string;
isButton?: boolean;
isActive?: boolean;
onClick?: () => void;
};
function MenuButton({
href,
icon,
label,
isButton = false,
isActive = false,
onClick,
}: MenuButtonProps) {
const touchTargetSize = "3.0rem";
const containerStyle = {
display: "flex",
flexDirection: "column" as const,
alignItems: "center",
justifyContent: "center",
gap: "0.25rem",
color: "#ccc",
textDecoration: "none",
cursor: "pointer",
transition: "color 0.2s ease",
background: "none",
border: "none",
padding: 0,
position: "relative" as const,
width: touchTargetSize,
minWidth: touchTargetSize,
};
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.color = "#fff";
};
const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.color = "#ccc";
};
const content = (
<>
{/* Active indicator line */}
{isActive && (
<ViewTransition name="footer-menu-active">
<div
style={{
position: "absolute",
top: "-0.75rem",
left: 0,
width: touchTargetSize,
height: "1px",
backgroundColor: "#fff",
}}
/>
</ViewTransition>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{icon}
</div>
<span
style={{
fontSize: "0.65rem",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
fontWeight: 500,
}}
></span>
</>
);
if (isButton) {
return (
<button
style={containerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
{content}
</button>
);
}
return (
<Link
href={href!}
title={label}
style={containerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{content}
</Link>
);
}
type HamburgerMenuItemProps = {
href: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
external?: boolean;
};
function HamburgerMenuItem({
href,
icon,
label,
onClick,
external = false,
}: HamburgerMenuItemProps) {
const content = (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem 1.5rem",
color: "#ccc",
textDecoration: "none",
cursor: "pointer",
transition: "background-color 0.2s ease, color 0.2s ease",
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#ccc";
}}
>
{icon}
<span
style={{
fontSize: "0.9rem",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
fontWeight: 500,
}}
>
{label}
</span>
</div>
);
if (external) {
return (
<a href={href} onClick={onClick} style={{ textDecoration: "none" }}>
{content}
</a>
);
}
return (
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
{content}
</Link>
);
}

View file

@ -1,103 +0,0 @@
"use server";
import { knex } from "../../../db";
import { markAsNSFW } from "../../../data/skins";
import UserContext from "../../../data/UserContext";
export async function logUserEvent(sessionId: string, event: UserEvent) {
const timestamp = Date.now();
console.log("Logging user event:", {
sessionId,
timestamp,
event,
});
await knex("user_log_events").insert({
session_id: sessionId,
timestamp: timestamp,
metadata: JSON.stringify(event),
});
// If this is a NSFW report, call the existing infrastructure
if (event.type === "skin_flag_nsfw") {
// Create an anonymous user context for the report
const ctx = new UserContext();
await markAsNSFW(ctx, event.skinMd5);
}
}
export type UserEvent =
| {
type: "session_start";
}
| {
type: "session_end";
reason: "unmount" | "before_unload";
}
/**
* @deprecated
*/
| {
type: "skin_view";
skinMd5: string;
}
| {
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: "skin_like";
skinMd5: string;
liked: boolean;
}
| {
type: "skin_flag_nsfw";
skinMd5: string;
}
| {
type: "share_open";
skinMd5: string;
}
| {
type: "share_success";
skinMd5: string;
}
| {
type: "share_failure";
skinMd5: string;
errorMessage: string;
}
| {
type: "menu_click";
menuItem: string;
}
| {
type: "scroll_hint_shown";
};

View file

@ -1,344 +0,0 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
useRef,
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useEffect,
} from "react";
import Link from "next/link";
import { FixedSizeGrid as Grid } from "react-window";
import { useWindowSize } from "../../../legacy-client/src/hooks";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
MOBILE_MAX_WIDTH,
} from "../../../legacy-client/src/constants";
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
import { searchSkins as performAlgoliaSearch } from "./algoliaClient";
// Simple utility to get screenshot URL (avoiding server-side import)
function getScreenshotUrl(md5: string): string {
return `https://r2.webampskins.org/screenshots/${md5}.png`;
}
type CellData = {
skins: GridSkin[];
columnCount: number;
width: number;
height: number;
loadMoreSkins: (startIndex: number) => Promise<void>;
};
function Cell({
columnIndex,
rowIndex,
style,
data,
}: {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
data: CellData;
}) {
const { skins, width, height, columnCount } = data;
const index = rowIndex * columnCount + columnIndex;
data.loadMoreSkins(index);
const skin = skins[index];
if (!skin) {
return null;
}
return (
<div style={style}>
<div style={{ width, height, position: "relative" }}>
<Link href={`/scroll/skin/${skin.md5}`}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</ViewTransition>
</Link>
{skin.nsfw && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "14px",
}}
>
NSFW
</div>
)}
</div>
</div>
);
}
type SkinTableProps = {
initialSkins: GridSkin[];
initialTotal: number;
};
export default function SkinTable({
initialSkins,
initialTotal,
}: SkinTableProps) {
const { windowWidth, windowHeight } = useWindowSize();
// Search input state - separate input value from actual search query
const [inputValue, setInputValue] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get("q") || "";
});
// State for browsing mode
const [browseSkins, setBrowseSkins] = useState<GridSkin[]>(initialSkins);
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([0]));
const isLoadingRef = useRef(false);
// State for search mode
const [searchSkins, setSearchSkins] = useState<GridSkin[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [_, setSearchIsPending] = useState(false);
// Debounce timer ref
// Determine which mode we're in based on actual search query, not input
const isSearchMode = inputValue.trim().length > 0;
const skins = isSearchMode ? searchSkins : browseSkins;
const total = isSearchMode ? searchSkins.length : initialTotal;
// Handle search input change
const handleSearchChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setInputValue(query);
};
useEffect(() => {
const query = inputValue;
const newUrl = query.trim() === "" ? "/scroll/" : `/scroll/?q=${query}`;
// window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
// If query is empty, clear results immediately
if (!query || query.trim().length === 0) {
setSearchSkins([]);
setSearchError(null);
return;
}
async function fetchResults() {
try {
setSearchIsPending(true);
const result = await performAlgoliaSearch(query);
const hits = result.hits as Array<{
objectID: string;
fileName: string;
nsfw?: boolean;
}>;
const searchResults: GridSkin[] = hits.map((hit) => ({
md5: hit.objectID,
screenshotUrl: getScreenshotUrl(hit.objectID),
fileName: hit.fileName,
nsfw: hit.nsfw ?? false,
}));
setSearchSkins(searchResults);
} catch (err) {
console.error("Search failed:", err);
setSearchError("Search failed. Please try again.");
setSearchSkins([]);
} finally {
setSearchIsPending(false);
}
}
fetchResults();
}, [inputValue]);
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9));
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
const pageSize = 50; // Number of skins to load per page
const loadMoreSkins = useCallback(
async (startIndex: number) => {
// Don't load more in search mode
if (isSearchMode) {
return;
}
const pageNumber = Math.floor(startIndex / pageSize);
// Don't reload if we already have this page
if (loadedPages.has(pageNumber) || isLoadingRef.current) {
return;
}
isLoadingRef.current = true;
try {
const offset = pageNumber * pageSize;
const newSkins = await getMuseumPageSkins(offset, pageSize);
setBrowseSkins((prev) => [...prev, ...newSkins]);
setLoadedPages((prev) => new Set([...prev, pageNumber]));
} catch (error) {
console.error("Failed to load skins:", error);
} finally {
isLoadingRef.current = false;
}
},
[loadedPages, pageSize, isSearchMode]
);
const itemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`;
},
[columnCount, skins]
);
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
const onScroll = useMemo(() => {
const half = Math.round(columnCount / 2);
return (scrollData: { scrollTop: number }) => {
itemRef.current =
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
};
}, [columnCount, rowHeight]);
const itemData: CellData = useMemo(
() => ({
skins,
columnCount,
width: columnWidth,
height: rowHeight,
loadMoreSkins,
}),
[skins, columnCount, columnWidth, rowHeight, loadMoreSkins]
);
return (
<div id="infinite-skins">
{/* Floating Search Bar */}
<div
style={{
position: "fixed",
bottom: "4.25rem",
left: "50%",
transform: "translateX(-50%)",
width: "calc(100% - 2rem)",
maxWidth: MOBILE_MAX_WIDTH,
padding: "0 1rem",
zIndex: 998,
}}
>
<div style={{ position: "relative" }}>
<input
type="search"
value={inputValue}
onChange={handleSearchChange}
placeholder="Search skins..."
style={{
width: "100%",
padding: "0.75rem 1rem",
paddingRight: "1rem",
fontSize: "1rem",
backgroundColor: "rgba(26, 26, 26, 0.55)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "9999px",
color: "#fff",
outline: "none",
fontFamily: "inherit",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
transition: "padding-right 0.2s ease",
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
/>
</div>
</div>
{/* Error State */}
{isSearchMode && searchError && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ff6b6b",
}}
>
{searchError}
</div>
)}
{/* Empty Results */}
{isSearchMode && !searchError && skins.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ccc",
}}
>
No results found for &quot;{inputValue}&quot;
</div>
)}
{/* Grid - show when browsing or when we have results (even while pending) */}
{(!isSearchMode || (!searchError && skins.length > 0)) && (
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={Math.ceil(total / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowsCount={5}
onScroll={onScroll}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
)}
</div>
);
}

View file

@ -1,198 +0,0 @@
"use client";
import {
useState,
useLayoutEffect,
useEffect,
useRef,
useCallback,
memo,
} from "react";
import { FixedSizeGrid as Grid } from "react-window";
import { useRouter } from "next/navigation";
import { ClientSkin } from "./SkinScroller";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "../../../legacy-client/src/constants";
type Props = {
initialSkins: ClientSkin[];
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
};
type CellProps = {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
data: {
skins: ClientSkin[];
columnCount: number;
requestSkinsIfNeeded: (index: number) => void;
};
};
// Extract Cell as a separate component so we can use hooks
const GridCell = memo(({ columnIndex, rowIndex, style, data }: CellProps) => {
const { skins, columnCount, requestSkinsIfNeeded } = data;
const router = useRouter();
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
// Request more skins if this cell needs data
useEffect(() => {
if (!skin) {
requestSkinsIfNeeded(index);
}
}, [skin, index, requestSkinsIfNeeded]);
if (!skin) {
return <div style={style} />;
}
return (
<div
style={{
...style,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a1a",
padding: "2px",
boxSizing: "border-box",
cursor: "pointer",
}}
onClick={() => {
router.push(`/scroll/skin/${skin.md5}`);
}}
>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
display: "block",
imageRendering: "pixelated",
objectFit: "cover",
}}
/>
</div>
</div>
);
});
GridCell.displayName = "GridCell";
// Calculate grid dimensions based on window width
// Skins will be scaled to fill horizontally across multiple columns
function getGridDimensions(windowWidth: number) {
const scale = 1.0; // Can be adjusted for different sizes
const columnCount = Math.max(
1,
Math.floor(windowWidth / (SCREENSHOT_WIDTH * scale))
);
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
return { columnWidth, rowHeight, columnCount };
}
export default function InfiniteScrollGrid({
initialSkins,
getSkins,
sessionId,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [fetching, setFetching] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
const [windowHeight, setWindowHeight] = useState(0);
const gridRef = useRef<Grid>(null);
const requestedIndicesRef = useRef<Set<number>>(new Set());
// Track window size
useLayoutEffect(() => {
function updateSize() {
setWindowWidth(window.innerWidth);
setWindowHeight(window.innerHeight);
}
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, []);
// Scroll to top when window width changes (column count changes)
useEffect(() => {
if (gridRef.current && windowWidth > 0) {
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
}
}, [windowWidth]);
// Function to request more skins when a cell needs data
const requestSkinsIfNeeded = useCallback(
(index: number) => {
// Only fetch if this index is beyond our current data
if (index >= skins.length) {
// Calculate which batch this index belongs to
const batchSize = 50; // Fetch in batches
const batchStart = Math.floor(skins.length / batchSize) * batchSize;
// Only fetch if we haven't already requested this batch
if (!requestedIndicesRef.current.has(batchStart) && !fetching) {
requestedIndicesRef.current.add(batchStart);
setFetching(true);
getSkins(sessionId, batchStart)
.then((newSkins) => {
setSkins((prevSkins) => [...prevSkins, ...newSkins]);
setFetching(false);
})
.catch(() => {
requestedIndicesRef.current.delete(batchStart);
setFetching(false);
});
}
}
},
[skins.length, fetching, sessionId, getSkins]
);
const { columnWidth, rowHeight, columnCount } =
getGridDimensions(windowWidth);
if (windowWidth === 0 || windowHeight === 0) {
return null; // Don't render until we have window dimensions
}
const rowCount = Math.ceil(skins.length / columnCount);
const itemData = {
skins,
columnCount,
requestSkinsIfNeeded,
};
return (
<div style={{ backgroundColor: "#1a1a1a" }}>
<Grid
ref={gridRef}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={rowCount}
rowHeight={rowHeight}
width={windowWidth}
itemData={itemData}
overscanRowCount={2}
style={{
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
}}
className="hide-scrollbar"
>
{GridCell}
</Grid>
</div>
);
}

View file

@ -1,233 +0,0 @@
"use client";
import { useState, ReactNode } from "react";
import { Heart, Share2, Flag, Download } from "lucide-react";
import { ClientSkin } from "./SkinScroller";
import { logUserEvent } from "./Events";
type Props = {
skin: ClientSkin;
sessionId: string;
};
export default function SkinActionIcons({ skin, sessionId }: Props) {
return (
<div
style={{
position: "absolute",
right: "1rem",
bottom: "2rem",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
paddingBottom: "1rem",
}}
>
<LikeButton skin={skin} sessionId={sessionId} />
<ShareButton skin={skin} sessionId={sessionId} />
<FlagButton skin={skin} sessionId={sessionId} />
<DownloadButton skin={skin} sessionId={sessionId} />
</div>
);
}
// Implementation details below
type ButtonProps = {
onClick: () => void;
disabled?: boolean;
opacity?: number;
"aria-label": string;
children: ReactNode;
};
function Button({
onClick,
disabled = false,
opacity = 1,
"aria-label": ariaLabel,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
background: "none",
border: "none",
cursor: disabled ? "default" : "pointer",
padding: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.25rem",
opacity,
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
}}
aria-label={ariaLabel}
>
{children}
</button>
);
}
type LikeButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function LikeButton({ skin, sessionId }: LikeButtonProps) {
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(skin.likeCount);
const handleLike = async () => {
const newLikedState = !isLiked;
setIsLiked(newLikedState);
// Optimistically update the like count
setLikeCount((prevCount) =>
newLikedState ? prevCount + 1 : prevCount - 1
);
logUserEvent(sessionId, {
type: "skin_like",
skinMd5: skin.md5,
liked: newLikedState,
});
};
return (
<Button onClick={handleLike} aria-label="Like">
<Heart
size={32}
color="white"
fill={isLiked ? "white" : "none"}
strokeWidth={2}
/>
{likeCount > 0 && (
<span
style={{
color: "white",
fontSize: "0.75rem",
fontWeight: "bold",
}}
>
{likeCount}
</span>
)}
</Button>
);
}
type ShareButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function ShareButton({ skin, sessionId }: ShareButtonProps) {
const handleShare = async () => {
if (navigator.share) {
try {
logUserEvent(sessionId, {
type: "share_open",
skinMd5: skin.md5,
});
await navigator.share({
title: skin.fileName,
text: `Check out this Winamp skin: ${skin.fileName}`,
url: skin.shareUrl,
});
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
} catch (error) {
// User cancelled or share failed
if (error instanceof Error && error.name !== "AbortError") {
console.error("Share failed:", error);
logUserEvent(sessionId, {
type: "share_failure",
skinMd5: skin.md5,
errorMessage: error.message,
});
}
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(skin.shareUrl);
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
alert("Share link copied to clipboard!");
}
};
return (
<Button onClick={handleShare} aria-label="Share">
<Share2 size={32} color="white" strokeWidth={2} />
</Button>
);
}
type FlagButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function FlagButton({ skin, sessionId }: FlagButtonProps) {
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
const handleFlagNsfw = async () => {
if (isFlagged) return; // Only allow flagging once
setIsFlagged(true);
logUserEvent(sessionId, {
type: "skin_flag_nsfw",
skinMd5: skin.md5,
});
};
return (
<Button
onClick={handleFlagNsfw}
disabled={isFlagged}
opacity={isFlagged ? 0.5 : 1}
aria-label="Flag as NSFW"
>
<Flag
size={32}
color="white"
fill={isFlagged ? "white" : "none"}
strokeWidth={2}
/>
</Button>
);
}
type DownloadButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
const handleDownload = async () => {
logUserEvent(sessionId, {
type: "skin_download",
skinMd5: skin.md5,
});
// Trigger download
window.location.href = skin.downloadUrl;
};
return (
<Button onClick={handleDownload} aria-label="Download">
<Download size={32} color="white" strokeWidth={2} />
</Button>
);
}

View file

@ -1,97 +0,0 @@
"use client";
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
import { unstable_ViewTransition as ViewTransition } from "react";
import { ClientSkin } from "./SkinScroller";
import SkinActionIcons from "./SkinActionIcons";
import WebampComponent from "./Webamp";
type Props = {
skin: ClientSkin;
index: number;
sessionId: string;
focused: boolean;
};
export default function SkinPage({ skin, index, sessionId, focused }: Props) {
const showWebamp = focused;
return (
<div
key={skin.md5}
skin-md5={skin.md5}
skin-index={index}
className="scroller"
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
width: "100%",
height: "100vh",
scrollSnapAlign: "start",
scrollSnapStop: "always",
position: "relative",
paddingTop: "2rem", // Space for top shadow
paddingBottom: "5rem", // Space for bottom menu bar
boxSizing: "border-box",
}}
>
<div style={{ position: "relative", flexShrink: 0 }}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
position: "relative",
width: "100%",
aspectRatio: "275 / 348",
imageRendering: "pixelated",
}}
/>
{showWebamp && (
<WebampComponent
skinUrl={skin.skinUrl}
closeModal={() => {}}
loaded={() => {}}
/>
)}
</ViewTransition>
<SkinActionIcons skin={skin} sessionId={sessionId} />
</div>
<div
style={{
color: "white",
paddingLeft: "0.5rem",
paddingTop: "0.5rem",
flexShrink: 0,
}}
>
<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>
);
}

View file

@ -1,216 +0,0 @@
"use client";
import { useState, useLayoutEffect, useEffect } from "react";
import SkinPage from "./SkinPage";
import { logUserEvent } from "./Events";
import { useScrollHint } from "./useScrollHint";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
export type ClientSkin = {
screenshotUrl: string;
skinUrl: string;
fileName: string;
md5: string;
readmeStart: string;
downloadUrl: string;
shareUrl: string;
nsfw: boolean;
likeCount: number;
};
type Props = {
initialSkins: ClientSkin[];
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
};
export default function SkinScroller({
initialSkins,
getSkins,
sessionId,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false);
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [hasEverScrolled, setHasEverScrolled] = useState(false);
// Track if user has ever scrolled to another skin
useEffect(() => {
if (visibleSkinIndex > 0) {
setHasEverScrolled(true);
}
}, [visibleSkinIndex]);
// Show scroll hint only if user has never scrolled to another skin
useScrollHint({
containerRef,
enabled: visibleSkinIndex === 0 && !hasEverScrolled,
onHintShown: () => {
logUserEvent(sessionId, {
type: "scroll_hint_shown",
});
},
});
useLayoutEffect(() => {
if (containerRef == null) {
return;
}
// Use IntersectionObserver for cross-browser compatibility (iOS doesn't support scrollsnapchange)
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// When an element becomes mostly visible (> 50% intersecting)
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const index = parseInt(
entry.target.getAttribute("skin-index") || "0",
10
);
setVisibleSkinIndex(index);
}
});
},
{
root: containerRef,
threshold: 0.5, // Trigger when 50% of the element is visible
}
);
// Observe all skin page elements
const skinElements = containerRef.querySelectorAll("[skin-index]");
skinElements.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
};
}, [containerRef, skins.length]);
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(() => {
// We want the URL and title to update as you scroll, but
// we can't trigger a NextJS navigation since that would remount the
// component. So, here we replicate the metadata behavior of the route.
const skinMd5 = skins[visibleSkinIndex].md5;
const newUrl = `/scroll/skin/${skinMd5}`;
window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
logUserEvent(sessionId, {
type: "skin_view_start",
skinMd5,
});
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);
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]);
return (
<>
<div
ref={setContainerRef}
style={{
maxWidth: MOBILE_MAX_WIDTH, // 9:16 aspect ratio for scroll, full width for grid
margin: "0 auto",
height: "100vh",
// width: "100%",
overflowY: "scroll",
scrollSnapType: "y mandatory",
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
}}
className="hide-scrollbar"
>
{skins.map((skin, i) => {
return (
<SkinPage
key={skin.md5}
skin={skin}
index={i}
focused={i === visibleSkinIndex}
sessionId={sessionId}
/>
);
})}
</div>
{/* Top shadow overlay */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "4rem",
background:
"linear-gradient(180deg, rgba(26, 26, 26, 0.8) 0%, rgba(26, 26, 26, 0.4) 50%, rgba(26, 26, 26, 0) 100%)",
pointerEvents: "none",
zIndex: 500,
}}
/>
</>
);
}

View file

@ -1,182 +0,0 @@
import { ReactNode, CSSProperties } from "react";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
type StaticPageProps = {
children: ReactNode;
};
export default function StaticPage({ children }: StaticPageProps) {
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#1a1a1a",
paddingBottom: "5rem", // Space for bottom menu bar
boxSizing: "border-box",
}}
>
<div
style={{
maxWidth: MOBILE_MAX_WIDTH,
margin: "0 auto",
padding: "2rem 1.5rem",
color: "#fff",
lineHeight: "1.6",
}}
>
{children}
</div>
</div>
);
}
// Styled heading components
export function Heading({ children }: { children: ReactNode }) {
return (
<h1
style={{
fontSize: "2rem",
marginBottom: "1.5rem",
fontWeight: 600,
}}
>
{children}
</h1>
);
}
export function Subheading({ children }: { children: ReactNode }) {
return (
<h2
style={{
fontSize: "1.5rem",
marginTop: "2rem",
marginBottom: "1rem",
fontWeight: 600,
}}
>
{children}
</h2>
);
}
// Styled link component
export function Link({
href,
children,
...props
}: {
href: string;
children: ReactNode;
target?: string;
rel?: string;
}) {
return (
<a
href={href}
style={{
color: "#6b9eff",
textDecoration: "underline",
}}
{...props}
>
{children}
</a>
);
}
// Styled paragraph component
export function Paragraph({ children }: { children: ReactNode }) {
return <p style={{ marginBottom: "1rem" }}>{children}</p>;
}
// Styled form components
export function Label({ children }: { children: ReactNode }) {
return (
<label
style={{
display: "block",
marginBottom: "0.5rem",
fontWeight: 500,
}}
>
{children}
</label>
);
}
export function Input({
style,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { style?: CSSProperties }) {
return (
<input
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "4px",
color: "#fff",
fontFamily: "inherit",
boxSizing: "border-box",
...style,
}}
{...props}
/>
);
}
export function Textarea({
style,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
style?: CSSProperties;
}) {
return (
<textarea
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "4px",
color: "#fff",
fontFamily: "inherit",
display: "block",
resize: "vertical",
boxSizing: "border-box",
...style,
}}
{...props}
/>
);
}
export function Button({
style,
disabled,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { style?: CSSProperties }) {
return (
<button
style={{
padding: "0.75rem 2rem",
fontSize: "1rem",
fontWeight: 500,
backgroundColor: "#6b9eff",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "opacity 0.2s",
...style,
}}
disabled={disabled}
{...props}
/>
);
}

View file

@ -1,208 +0,0 @@
import React, { useEffect, useRef } from "react";
import {
SCREENSHOT_HEIGHT,
SCREENSHOT_WIDTH,
} from "../../../legacy-client/src/constants";
type Props = {
skinUrl: string;
closeModal: () => void;
loaded: () => void;
};
export default function WebampComponent({
skinUrl,
closeModal,
loaded,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
// @ts-ignore
const webampRef = useRef<import("webamp").default | null>(null);
useEffect(() => {
let disposed = false;
let cleanup = () => {};
async function loadWebamp() {
// @ts-ignore
const { default: Webamp } = await import("webamp");
if (disposed) return;
const webamp = new Webamp({
initialSkin: { url: skinUrl },
initialTracks,
enableHotkeys: true,
zIndex: 1001,
});
webampRef.current = webamp;
cleanup = () => webamp.dispose();
webamp.onClose(closeModal);
// ref.current!.style.opacity = "0";
await webamp.renderInto(ref.current!);
const { width } = outerRef.current!.getBoundingClientRect();
const zoom = width / SCREENSHOT_WIDTH;
document
.getElementById("webamp")
?.style.setProperty("zoom", String(zoom));
ref.current!.style.opacity = "1";
if (!disposed) loaded();
}
loadWebamp();
return () => {
disposed = true;
cleanup();
};
}, [skinUrl, closeModal, loaded]);
return (
<div
ref={outerRef}
style={{
top: 0,
position: "absolute",
width: "100%",
height: "100%",
}}
>
<div
className="webamp-container"
style={{
width: SCREENSHOT_WIDTH,
height: SCREENSHOT_HEIGHT,
position: "relative",
opacity: 0,
transition: "opacity 1s linear",
}}
ref={ref}
/>
</div>
);
}
const album = "netBloc Vol. 24: tiuqottigeloot";
const initialTracks = [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
url: "/llama.mp3",
duration: 5.322286,
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
duration: 322.612245,
metaData: {
title: "Heroines",
artist: "Diablo Swing Orchestra",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
duration: 190.093061,
metaData: {
title: "We Are Going To Eclecfunk Your Ass",
artist: "Eclectek",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Auto-Pilot_-_03_-_Seventeen.mp3",
duration: 214.622041,
metaData: {
title: "Seventeen",
artist: "Auto-Pilot",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Muha_-_04_-_Microphone.mp3",
duration: 181.838367,
metaData: {
title: "Microphone",
artist: "Muha",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Just_Plain_Ant_-_05_-_Stumble.mp3",
duration: 86.047347,
metaData: {
title: "Stumble",
artist: "Just Plain Ant",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Sleaze_-_06_-_God_Damn.mp3",
duration: 226.795102,
metaData: {
title: "God Damn",
artist: "Sleaze",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Juanitos_-_07_-_Hola_Hola_Bossa_Nova.mp3",
duration: 207.072653,
metaData: {
title: "Hola Hola Bossa Nova",
artist: "Juanitos",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Entertainment_for_the_Braindead_-_08_-_Resolutions_Chris_Summer_Remix.mp3",
duration: 314.331429,
metaData: {
title: "Resolutions (Chris Summer Remix)",
artist: "Entertainment for the Braindead",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Nobara_Hayakawa_-_09_-_Trail.mp3",
duration: 204.042449,
metaData: {
title: "Trail",
artist: "Nobara Hayakawa",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Paper_Navy_-_10_-_Tongue_Tied.mp3",
duration: 201.116735,
metaData: {
title: "Tongue Tied",
artist: "Paper Navy",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/60_Tigres_-_11_-_Garage.mp3",
duration: 245.394286,
metaData: {
title: "Garage",
artist: "60 Tigres",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/CM_aka_Creative_-_12_-_The_Cycle_Featuring_Mista_Mista.mp3",
duration: 221.44,
metaData: {
title: "The Cycle (Featuring Mista Mista)",
artist: "CM aka Creative",
album,
},
},
];

View file

@ -1,67 +0,0 @@
import StaticPage, {
Heading,
Subheading,
Link,
Paragraph,
} from "../StaticPage";
export default function AboutPage() {
return (
<StaticPage>
<Heading>About</Heading>
<Paragraph>
The Winamp Skin Museum is an attempt to build a <i>fast</i>,{" "}
<i>searchable</i>, and <i>shareable</i>, interface for the collection of
Winamp Skins amassed on the{" "}
<Link
href="https://archive.org/details/winampskins"
target="_blank"
rel="noopener noreferrer"
>
Internet Archive
</Link>
.
</Paragraph>
<Subheading>Features:</Subheading>
<ul style={{ marginBottom: "1.5rem", paddingLeft: "1.5rem" }}>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Infinite scroll</strong> preview images
</li>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Experience</strong> skins with integrated{" "}
<Link
href="https://webamp.org"
target="_blank"
rel="noopener noreferrer"
>
Webamp
</Link>
</li>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Fast search</strong> of indexed readme.txt texts
</li>
</ul>
<Paragraph>
Made by <Link href="https://jordaneldredge.com">Jordan Eldredge</Link>
</Paragraph>
<hr
style={{
border: "none",
borderTop: "1px solid rgba(255, 255, 255, 0.2)",
margin: "2rem 0",
}}
/>
<Paragraph>
Want Winamp on your Windows PC, but with supported updates & new
features?{" "}
<Link
href="https://getwacup.com/"
target="_blank"
rel="noopener noreferrer"
>
Try WACUP
</Link>
</Paragraph>
</StaticPage>
);
}

View file

@ -1,18 +0,0 @@
import { algoliasearch } from "algoliasearch";
// Using the legacy hardcoded credentials for client-side search
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
export async function searchSkins(query: string) {
const result = await client.searchSingleIndex({
indexName: "Skins",
searchParams: {
query,
attributesToRetrieve: ["objectID", "fileName", "nsfw"],
attributesToHighlight: [],
hitsPerPage: 1000,
typoTolerance: "min",
},
});
return result;
}

View file

@ -1,117 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import StaticPage, {
Heading,
Paragraph,
Label,
Input,
Textarea,
Button,
} from "../StaticPage";
async function sendFeedback(variables: {
message: string;
email: string;
url: string;
}) {
const mutation = `
mutation GiveFeedback($message: String!, $email: String, $url: String) {
send_feedback(message: $message, email: $email, url: $url)
}
`;
const response = await fetch("/api/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: mutation,
variables,
}),
});
if (!response.ok) {
throw new Error("Failed to send feedback");
}
return response.json();
}
export default function FeedbackPage() {
const pathname = usePathname();
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const send = useCallback(async () => {
if (message.trim().length === 0) {
alert("Please add a message before sending.");
return;
}
const body = {
message,
email,
url: "https://skins.webamp.org" + pathname,
};
setSending(true);
try {
await sendFeedback(body);
setSent(true);
} catch (_) {
alert("Failed to send feedback. Please try again.");
setSending(false);
}
}, [message, email, pathname]);
if (sent) {
return (
<StaticPage>
<Heading>Sent!</Heading>
<Paragraph>
Thanks for your feedback. I appreciate you taking the time to share
your thoughts.
</Paragraph>
</StaticPage>
);
}
return (
<StaticPage>
<Heading>Feedback</Heading>
<p style={{ marginBottom: "1.5rem" }}>
Let me know what you think about the Winamp Skin Museum. Bug reports,
feature suggestions, personal anecdotes, or criticism are all welcome.
</p>
<div style={{ marginBottom: "1.5rem" }}>
<Label>Message</Label>
<Textarea
disabled={sending}
value={message}
onChange={(e) => setMessage(e.target.value)}
style={{ minHeight: 150 }}
placeholder="Your thoughts here..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<Label>Email (optional)</Label>
<Input
disabled={sending}
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div style={{ textAlign: "right" }}>
<Button onClick={send} disabled={sending}>
{sending ? "Sending..." : "Send"}
</Button>
</div>
</StaticPage>
);
}

View file

@ -1,48 +0,0 @@
import UserContext from "../../../data/UserContext";
import SessionModel from "../../../data/SessionModel";
import { ClientSkin } from "./SkinScroller";
import { getScrollPage } from "../../../data/skins";
import SkinModel from "../../../data/SkinModel";
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
export async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
"use server";
const ctx = new UserContext();
const page = await getScrollPage(sessionId);
return await Promise.all(
page.map(async (item) => {
return getSkinForSession(ctx, sessionId, item.md5);
})
);
}
export async function getSkinForSession(
ctx: UserContext,
sessionId: string,
md5: string
): Promise<ClientSkin> {
const model = await SkinModel.fromMd5Assert(ctx, md5);
const readmeText = await model.getReadme();
const fileName = await model.getFileName();
const tweet = await model.getTweet();
const likeCount = tweet ? tweet.getLikes() : 0;
SessionModel.addSkin(sessionId, md5);
return {
screenshotUrl: model.getScreenshotUrl(),
skinUrl: model.getSkinUrl(),
md5,
// TODO: Normalize to .wsz
fileName: fileName,
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
downloadUrl: model.getSkinUrl(),
shareUrl: `https://skins.webamp.org/scroll/skin/${md5}`,
nsfw: await model.getIsNsfw(),
likeCount: likeCount,
};
}

View file

@ -1,26 +0,0 @@
"use server";
import { getMuseumPage, getScreenshotUrl } from "../../../data/skins";
export type GridSkin = {
md5: string;
screenshotUrl: string;
fileName: string;
nsfw: boolean;
};
export async function getMuseumPageSkins(
offset: number,
limit: number
): Promise<GridSkin[]> {
const page = await getMuseumPage({ offset, first: limit });
const skins = page.map((item) => ({
md5: item.md5,
screenshotUrl: getScreenshotUrl(item.md5),
fileName: item.fileName,
nsfw: item.nsfw,
}));
return skins;
}

View file

@ -1,26 +0,0 @@
"use client";
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
import { unstable_ViewTransition as ViewTransition, ReactNode } from "react";
import BottomMenuBar from "./BottomMenuBar";
import "./scroll.css";
type LayoutProps = {
children: ReactNode;
};
export default function Layout({ children }: LayoutProps) {
return (
<div
style={{
position: "relative",
height: "100vh",
}}
>
{children}
<ViewTransition>
<BottomMenuBar />
</ViewTransition>
</div>
);
}

View file

@ -1,12 +0,0 @@
import React from "react";
import Grid from "./Grid";
import { getMuseumPageSkins } from "./getMuseumPageSkins";
import * as Skins from "../../..//data/skins";
export default async function SkinTable() {
const [initialSkins, skinCount] = await Promise.all([
getMuseumPageSkins(0, 50),
Skins.getClassicSkinCount(),
]);
return <Grid initialSkins={initialSkins} initialTotal={skinCount} />;
}

View file

@ -1,22 +0,0 @@
body {
margin: 0; /* Remove default margin */
height: 100vh; /* Set body height to viewport height */
background-color: #1a1a1a; /* Dark charcoal instead of pure black */
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
input,
button {
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
.scroller::-webkit-scrollbar,
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.scroller,
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View file

@ -1,33 +0,0 @@
import { Metadata } from "next";
import SessionModel from "../../../../../data/SessionModel";
import UserContext from "../../../../../data/UserContext";
import { getClientSkins, getSkinForSession } from "../../getClientSkins";
import SkinScroller from "../../SkinScroller";
import { generateSkinPageMetadata } from "../../../../(legacy)/skin/[hash]/skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { md5 } = await params;
return generateSkinPageMetadata(md5);
}
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
export default async function Skin({ params }) {
const { md5 } = await params;
// Create the session in the database
const sessionId = await SessionModel.create();
const ctx = new UserContext();
const linked = await getSkinForSession(ctx, sessionId, md5);
const initialSkins = await getClientSkins(sessionId);
return (
<SkinScroller
initialSkins={[linked, ...initialSkins]}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
}

View file

@ -1,24 +0,0 @@
import SessionModel from "../../../../data/SessionModel";
import { getClientSkins } from "../getClientSkins";
import SkinScroller from "../SkinScroller";
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
/**
* A tik-tok style scroll page where we display one skin at a time in full screen
*/
export default async function ScrollPage() {
// Create the session in the database
const sessionId = await SessionModel.create();
const initialSkins = await getClientSkins(sessionId);
return (
<SkinScroller
initialSkins={initialSkins}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
}

View file

@ -1,99 +0,0 @@
import { useEffect } from "react";
type UseScrollHintOptions = {
containerRef: HTMLDivElement | null;
enabled: boolean;
delayMs?: number;
scrollAmount?: number;
animationDuration?: number;
onHintShown?: () => void;
};
/**
* A hook that provides a gentle scroll hint animation to encourage user interaction.
* After a delay, if the user hasn't scrolled, it will scroll down slightly and bounce back.
*/
export function useScrollHint({
containerRef,
enabled,
delayMs = 2000,
scrollAmount = 80,
animationDuration = 1000,
onHintShown,
}: UseScrollHintOptions) {
useEffect(() => {
if (containerRef == null || !enabled) {
return;
}
const hintTimer = setTimeout(() => {
if (!enabled || containerRef.scrollTop !== 0) {
return;
}
const startScrollTop = containerRef.scrollTop;
const startTime = Date.now();
// Temporarily disable scroll snap for smooth animation
const originalScrollSnapType = containerRef.style.scrollSnapType;
containerRef.style.scrollSnapType = "none";
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / animationDuration, 1);
// Bouncy easing function - overshoots and bounces back
const easeOutBounce = (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
};
// Create a bounce effect: scroll down quickly, then bounce back
let offset;
if (progress < 0.4) {
// First 40%: scroll down quickly
const t = progress / 0.4;
offset = scrollAmount * t * t; // Quadratic ease-in
} else {
// Last 60%: bounce back with overshoot
const t = (progress - 0.4) / 0.6;
offset = scrollAmount * (1 - easeOutBounce(t));
}
containerRef.scrollTop = startScrollTop + offset;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
// Ensure we end exactly where we started
containerRef.scrollTop = startScrollTop;
// Re-enable scroll snap
containerRef.style.scrollSnapType = originalScrollSnapType;
}
};
animate();
onHintShown?.();
}, delayMs);
return () => {
clearTimeout(hintTimer);
};
}, [
containerRef,
enabled,
delayMs,
scrollAmount,
animationDuration,
onHintShown,
]);
}

View file

@ -111,8 +111,8 @@ function SkinTableUnbound({
}
return skin ? skin.hash : `unfectched-index-${requestToken}`;
}
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
const gridRef = React.useRef();
const itemRef = React.useRef();
React.useLayoutEffect(() => {
if (gridRef.current == null) {
return;

View file

@ -1,4 +1,4 @@
import App from "./App";
import App from "../App";
import type { Metadata } from "next";
const DESCRIPTION =

View file

@ -1,483 +0,0 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchGraphql, gql } from "../../legacy-client/src/utils";
interface BulkDownloadSkin {
md5: string;
filename: string;
download_url: string;
__typename: string;
}
interface DownloadProgress {
totalSkins: number;
completedSkins: number;
failedSkins: number;
estimatedSizeBytes: string;
activeDownloads: Array<{
filename: string;
md5: string;
status: "downloading" | "failed";
error?: string;
}>;
}
interface DirectoryHandle {
name: string;
getDirectoryHandle: (
name: string,
options?: { create?: boolean }
) => Promise<DirectoryHandle>;
getFileHandle: (
name: string,
options?: { create?: boolean }
) => Promise<FileSystemFileHandle>;
}
declare global {
interface Window {
showDirectoryPicker?: () => Promise<DirectoryHandle>;
}
}
const BULK_DOWNLOAD_QUERY = gql`
query BulkDownload($offset: Int!, $first: Int!) {
bulkDownload(offset: $offset, first: $first) {
totalCount
estimatedSizeBytes
nodes {
__typename
md5
filename(normalize_extension: true, include_museum_id: true)
download_url
}
}
}
`;
const MAX_CONCURRENT_DOWNLOADS = 6;
const CHUNK_SIZE = 1000;
export default function BulkDownloadPage() {
const [directoryHandle, setDirectoryHandle] =
useState<DirectoryHandle | null>(null);
const [progress, setProgress] = useState<DownloadProgress>({
totalSkins: 0,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes: "0",
activeDownloads: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSupported] = useState(
typeof window !== "undefined" && "showDirectoryPicker" in window
);
const abortController = useRef<AbortController | null>(null);
const downloadSkin = useCallback(
async (
skin: BulkDownloadSkin,
directoryHandle: DirectoryHandle,
signal: AbortSignal
): Promise<void> => {
const { filename, download_url, md5 } = skin;
// Get the target directory and file path
const targetDirectory = await getDirectoryForSkin(
filename,
directoryHandle
);
// Check if file already exists
try {
await targetDirectory.getFileHandle(filename);
// File exists, skip download
console.log(`Skipping ${filename} - already exists`);
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
}));
return;
} catch (_) {
// File doesn't exist, continue with download
}
// Add to active downloads
setProgress((prev) => ({
...prev,
activeDownloads: [
...prev.activeDownloads,
{
filename,
md5,
status: "downloading" as const,
},
],
}));
try {
const response = await fetch(download_url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// We don't need individual progress tracking anymore
// const contentLength = parseInt(response.headers.get("content-length") || "0", 10);
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
// Use the targetDirectory and finalFilename we calculated earlier
const fileHandle = await targetDirectory.getFileHandle(filename, {
create: true,
});
const writable = await fileHandle.createWritable();
// Track total bytes for this file (not needed for individual progress)
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
// Mark as completed and immediately remove from active downloads
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
} catch (writeError) {
await writable.abort("Failed to write file");
throw writeError;
}
} catch (error: any) {
if (error.name === "AbortError") {
console.log(`Download aborted: ${filename}`);
throw error; // Re-throw abort errors
}
// Mark as failed and schedule removal
setProgress((prev) => ({
...prev,
failedSkins: prev.failedSkins + 1,
activeDownloads: prev.activeDownloads.map((d) =>
d.md5 === md5
? {
...d,
status: "failed" as const,
error: error.message,
}
: d
),
}));
// Remove failed download after 3 seconds
setTimeout(() => {
setProgress((prev) => ({
...prev,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
}, 3000);
console.error(`Failed to download ${filename}:`, error);
}
},
[getDirectoryForSkin]
);
// Load initial metadata when component mounts
useEffect(() => {
async function loadInitialData() {
try {
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress((prev) => ({
...prev,
totalSkins: totalCount,
estimatedSizeBytes,
}));
} catch (error: any) {
console.error("Failed to load initial data:", error);
setError("Failed to load skin count information");
}
}
loadInitialData();
}, [fetchSkins]);
const selectDirectoryAndStart = useCallback(async () => {
// First, select directory if not already selected
if (!directoryHandle) {
if (!window.showDirectoryPicker) {
setError(
"File System Access API is not supported in this browser. Please use Chrome or Edge."
);
return;
}
try {
const handle = await window.showDirectoryPicker();
setDirectoryHandle(handle);
setError(null);
// Now start the download with the new directory
await startDownloadWithDirectory(handle as FileSystemDirectoryHandle);
} catch (err: any) {
if (err.name !== "AbortError") {
setError(`Failed to select directory: ${err.message}`);
}
}
} else {
// Directory already selected, just start download
await startDownloadWithDirectory(
directoryHandle as FileSystemDirectoryHandle
);
}
}, [directoryHandle]);
const startDownloadWithDirectory = useCallback(
async (handle: FileSystemDirectoryHandle) => {
setIsDownloading(true);
setError(null);
// setStartTime(Date.now());
abortController.current = new AbortController();
try {
// Get initial metadata
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress({
totalSkins: totalCount,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes,
activeDownloads: [],
});
let offset = 0;
const activePromises = new Set<Promise<void>>();
while (offset < totalCount && !abortController.current.signal.aborted) {
console.log(`Fetching batch: offset=${offset}, chunk=${CHUNK_SIZE}`);
try {
const { skins } = await fetchSkins(offset, CHUNK_SIZE);
console.log(`Retrieved ${skins.length} skins in this batch`);
if (skins.length === 0) {
console.log("No more skins to fetch, breaking");
break;
}
for (const skin of skins) {
// eslint-disable-next-line max-depth
if (abortController.current.signal.aborted) break;
await waitForAvailableSlot(
activePromises,
abortController.current.signal
);
// eslint-disable-next-line max-depth
if (abortController.current.signal.aborted) break;
const downloadPromise = downloadSkin(
skin,
handle,
abortController.current.signal
).finally(() => {
activePromises.delete(downloadPromise);
});
activePromises.add(downloadPromise);
}
offset += skins.length;
console.log(`Completed batch, new offset: ${offset}/${totalCount}`);
} catch (error: any) {
console.error(`Failed to fetch batch at offset ${offset}:`, error);
setError(`Failed to fetch skins: ${error.message}`);
break;
}
}
// Wait for all remaining downloads to complete
await Promise.allSettled(activePromises);
console.log("All downloads completed!");
} catch (error: any) {
if (error.name !== "AbortError") {
setError(`Download failed: ${error.message}`);
}
} finally {
setIsDownloading(false);
}
},
[fetchSkins, downloadSkin]
);
const stopDownload = useCallback(() => {
if (abortController.current) {
abortController.current.abort("User Canceled");
}
setIsDownloading(false);
// setStartTime(null);
}, []);
const progressPercent =
progress.totalSkins > 0
? ((progress.completedSkins + progress.failedSkins) /
progress.totalSkins) *
100
: 0;
if (!isSupported) {
return <h1>Your browser does not support filesystem access.</h1>;
}
const gb = Math.round(
parseInt(progress.estimatedSizeBytes || "0", 10) / (1024 * 1024 * 1024)
);
return (
<div>
<div>
<div>
<div>
<h1>Bulk Download All Skins</h1>
<p>Download the entire Winamp Skin Museum collection.</p>
<ul>
<li>
Will download {progress.totalSkins.toLocaleString()} files (~
{gb}
GB) into the selected directory
</li>
<li>
Files will be organized into directories (aa-zz, 0-9) based on
filename prefix
</li>
<li>
Supports resuming from previously interrupted bulk download
</li>
</ul>
</div>
</div>
{error && (
<div>
<div>{error}</div>
</div>
)}
{/* Download Controls */}
<div>
{isDownloading ? (
<button onClick={stopDownload}>Stop Download</button>
) : (
<button onClick={selectDirectoryAndStart}>
{directoryHandle
? "Start Download"
: "Select Directory & Start Download"}
</button>
)}
</div>
{/* Progress Section */}
{(isDownloading || progress.completedSkins > 0) && (
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>
Downloaded{" "}
{(
progress.completedSkins + progress.failedSkins
).toLocaleString()}{" "}
of {progress.totalSkins.toLocaleString()} skins
</span>
<span>{Math.round(progressPercent)}% complete</span>
</div>
<div
style={{
border: "1px solid black",
}}
>
<div
style={{
background: "black",
transition: "all 300ms",
height: "18px",
width: `${progressPercent}%`,
}}
/>
</div>
</div>
)}
</div>
</div>
);
}
async function getDirectoryForSkin(
filename: string,
rootHandle: DirectoryHandle
) {
// Create directory based on first two characters of filename (case insensitive)
const firstChar = filename.charAt(0).toLowerCase();
const secondChar =
filename.length > 1 ? filename.charAt(1).toLowerCase() : "";
let dirName: string;
if (/[a-z]/.test(firstChar)) {
// For letters, use two-character prefix if second char is alphanumeric
if (/[a-z0-9]/.test(secondChar)) {
dirName = firstChar + secondChar;
} else {
// Fallback to single letter + 'x' for special characters
dirName = firstChar + "x";
}
} else {
// For numbers/symbols, use "0-9"
dirName = "0-9";
}
try {
return await rootHandle.getDirectoryHandle(dirName, { create: true });
} catch (err) {
console.warn(`Failed to create directory ${dirName}, using root:`, err);
return rootHandle;
}
}
async function fetchSkins(
offset: number,
first: number
): Promise<{
skins: BulkDownloadSkin[];
totalCount: number;
estimatedSizeBytes: string;
}> {
const { bulkDownload } = await fetchGraphql(BULK_DOWNLOAD_QUERY, {
offset,
first,
});
return {
skins: bulkDownload.nodes,
totalCount: bulkDownload.totalCount,
estimatedSizeBytes: bulkDownload.estimatedSizeBytes,
};
}
// Helper function to wait for an available download slot
async function waitForAvailableSlot(
activePromises: Set<Promise<void>>,
signal: AbortSignal
) {
while (activePromises.size >= MAX_CONCURRENT_DOWNLOADS && !signal.aborted) {
await Promise.race(activePromises);
}
}

View file

@ -10,8 +10,8 @@ const { handleRequest } = createYogaInstance({
return new UserContext();
},
logger: {
log: (_message: string, _context: Record<string, any>) => {
// console.log(message, context);
log: (message: string, context: Record<string, any>) => {
console.log(message, context);
},
logError: (message: string, context: Record<string, any>) => {
console.error(message, context);

View file

@ -1,3 +1,4 @@
import App from "../../../App";
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "../skinMetadata";
@ -7,5 +8,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
}
export default function Page() {
return null;
return <App />;
}

View file

@ -1,3 +1,4 @@
import App from "../../App";
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "./skinMetadata";
@ -7,5 +8,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
}
export default function Page() {
return null;
return <App />;
}

View file

@ -1,6 +1,6 @@
import { Metadata } from "next";
import SkinModel from "../../../../data/SkinModel";
import UserContext from "../../../../data/UserContext";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
export async function generateSkinPageMetadata(
hash: string

View file

@ -35,14 +35,11 @@ import { setHashesForSkin } from "./skinHash";
import * as S3 from "./s3";
import { generateDescription } from "./services/openAi";
import KeyValue from "./data/KeyValue";
import { postToBluesky } from "./tasks/bluesky";
import { computeSkinRankings } from "./tasks/computeScrollRanking";
async function withHandler(
cb: (handler: DiscordEventHandler) => Promise<void>
) {
const handler = new DiscordEventHandler();
await handler._clientPromise; // Ensure client is initialized
try {
await cb(handler);
} finally {
@ -84,30 +81,21 @@ program
.argument("[md5]", "md5 of the skin to share")
.option("-t, --twitter", "Share on Twitter")
.option("-i, --instagram", "Share on Instagram")
.option("-b, --bluesky", "Share on Bluesky")
.option("-m, --mastodon", "Share on Mastodon")
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
.action(async (md5, { twitter, instagram, mastodon }) => {
if (!twitter && !instagram && !mastodon) {
throw new Error("Expected at least one of --twitter or --instagram");
}
await withDiscordClient(async (client) => {
if (twitter) {
await tweet(client, md5);
return;
}
if (instagram) {
await insta(client, md5);
return;
}
if (mastodon) {
await postToMastodon(client, md5);
return;
}
if (bluesky) {
await postToBluesky(client, md5);
return;
}
throw new Error(
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
);
});
});
@ -173,7 +161,7 @@ program
console.log("====================================");
}
if (purge) {
// cat purge | xargs -I {} pnpm cli skin --purge {}
// cat purge | xargs -I {} yarn cli skin --purge {}
await Skins.deleteSkin(md5);
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
const purged = new Set(purgedArr);
@ -310,14 +298,6 @@ program
console.table([await Skins.getStats()]);
});
program
.command("compute-scroll-ranking")
.description("Analyze user event data and compute skin ranking scores.")
.action(async () => {
const rankings = await computeSkinRankings();
console.log(JSON.stringify(rankings, null, 2));
});
program
.command("process-uploads")
.description("Process any unprocessed user uploads.")
@ -458,7 +438,7 @@ program
);
const md5s = rows.map((row) => row.md5);
console.log(md5s.length);
console.log(await Skins.updateSearchIndexes(ctx, md5s));
console.log(await Skins.updateSearchIndexs(ctx, md5s));
}
if (refreshContentHash) {
const ctx = new UserContext();

View file

@ -29,8 +29,6 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
// Used for session encryption
export const SECRET = env("SECRET");
export const NODE_ENV = env("NODE_ENV") || "production";
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
function env(key: string): string {
const value = process.env[key];

View file

@ -1,29 +0,0 @@
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;
}
}

View file

@ -158,7 +158,7 @@ export default class SkinModel {
return "UNREVIEWED";
}
async getFileName(normalizeExtension?: boolean): Promise<string> {
async getFileName(): Promise<string> {
const files = await this.getFiles();
if (files.length === 0) {
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
@ -167,9 +167,6 @@ export default class SkinModel {
if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) {
throw new Error("Expected filename to end with zip, wsz or wal.");
}
if (normalizeExtension) {
return path.parse(filename).name + ".wsz";
}
return filename;
}
@ -196,6 +193,9 @@ export default class SkinModel {
const filename = file.getFileName();
const isReadme = IS_README.test(filename);
const isNotReadme = IS_NOT_README.test(filename);
console.log({ filename, isReadme, isNotReadme, md5: file.getFileMd5() });
return isReadme && !isNotReadme;
});
@ -232,8 +232,8 @@ export default class SkinModel {
}
}
getAverageColor(): string | null {
return this.row.average_color ?? null;
getAverageColor(): string {
return this.row.average_color;
}
getBuffer = mem(async (): Promise<Buffer> => {

View file

@ -144,17 +144,6 @@ export async function markAsPostedToMastodon(
);
}
export async function markAsPostedToBlueSky(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("bluesky_posts").insert(
{ skin_md5: md5, post_id: postId, url },
[]
);
}
// TODO: Also path actor
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
const index = { objectID: md5, nsfw: true };
@ -254,7 +243,7 @@ async function getSearchIndexes(
);
}
export async function updateSearchIndexes(
export async function updateSearchIndexs(
ctx: UserContext,
md5s: string[]
): Promise<any> {
@ -276,7 +265,7 @@ export async function updateSearchIndex(
ctx: UserContext,
md5: string
): Promise<any | null> {
return updateSearchIndexes(ctx, [md5]);
return updateSearchIndexs(ctx, [md5]);
}
export async function hideSkin(md5: string): Promise<void> {
@ -411,7 +400,6 @@ export async function getErroredUpload(): Promise<{
.where("status", "ERRORED")
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
.where("skin_md5", "!=", "a418fd00583006b6e79cf0b251c43771")
.first(["skin_md5", "id", "filename"]);
return found || null;
}
@ -562,31 +550,6 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
return skin.md5;
}
export async function getSkinToPostToBluesky(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const postables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"bluesky_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.where("likes", ">", 10)
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = postables[0];
if (skin == null) {
return null;
}
return skin.md5;
}
export async function getUnreviewedSkinCount(): Promise<number> {
const rows = await knex("skins")
.where({ skin_type: 1 })
@ -719,53 +682,6 @@ 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,

View file

@ -0,0 +1,28 @@
// To toggle between blue and green deployments:
// sudo vim /etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf
// Update port number
// sudo systemctl reload apache2
module.exports = {
apps: [
{
name: "skin-database-blue",
script: "yarn",
interpreter: "bash",
args: "start",
env: {
NODE_ENV: "production",
PORT: 3001,
},
},
{
name: "skin-database-green",
script: "yarn",
interpreter: "bash",
args: "start",
env: {
NODE_ENV: "production",
PORT: 3002,
},
},
],
};

View file

@ -31,5 +31,3 @@ process.env.INSTAGRAM_ACCESS_TOKEN = "<DUMMY>";
process.env.INSTAGRAM_ACCOUNT_ID = "<DUMMY>";
process.env.MASTODON_ACCESS_TOKEN = "<DUMMY>";
process.env.SECRET = "<DUMMY>";
process.env.BLUESKY_PASSWORD = "<DUMMY>";
process.env.BLUESKY_USERNAME = "<DUMMY>";

View file

@ -1,21 +1,18 @@
"use client";
import React, { useCallback } from "react";
import { connect, useSelector } from "react-redux";
import { connect } from "react-redux";
import About from "./About";
import Feedback from "./Feedback";
import Header from "./Header";
import Overlay from "./Overlay";
import SkinTable from "./SkinTable";
import FocusedSkin from "./FocusedSkin";
import { useSelector } from "react-redux";
import * as Selectors from "./redux/selectors";
import * as Actions from "./redux/actionCreators";
import {
ABOUT_PAGE,
REVIEW_PAGE,
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "./constants";
import { ABOUT_PAGE, REVIEW_PAGE } from "./constants";
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
import { SCREENSHOT_WIDTH, SKIN_RATIO } from "./constants";
import UploadGrid from "./upload/UploadGrid";
import Metadata from "./components/Metadata";
import SkinReadme from "./SkinReadme";
@ -81,7 +78,6 @@ function App(props) {
windowWidth={windowWidthWithScrollabar}
/>
)}
{/* eslint-disable-next-line no-nested-ternary -- legacy code */}
{props.showFeedbackForm ? (
<Overlay>
<Feedback />

View file

@ -1,7 +1,9 @@
import React, { useState, useCallback } from "react";
import React, { useState } from "react";
import { getUrl } from "./redux/selectors";
import * as Actions from "./redux/actionCreators";
import { useActionCreator } from "./hooks";
import { useCallback } from "react";
import { useSelector } from "react-redux";
import { fetchGraphql, gql } from "./utils";

View file

@ -1,5 +1,4 @@
import * as Utils from "./utils";
import { gql } from "./utils";
import { algoliasearch } from "algoliasearch";
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
@ -7,15 +6,14 @@ const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
// Fallback search that uses SQLite. Useful for when we've exceeded the Algolia
// search quota.
export async function graphqlSearch(query) {
const queryText = gql`
query SearchQuery($query: String!) {
search_classic_skins(query: $query, first: 500) {
filename(normalize_extension: true)
md5
nsfw
}
}
`;
const queryText = Utils.gql`
query SearchQuery($query: String!) {
search_classic_skins(query: $query, first: 500) {
filename
md5
nsfw
}
}`;
const data = await Utils.fetchGraphql(queryText, { query });
const hits = data.search_classic_skins.map((skin) => {
return {

View file

@ -3,7 +3,7 @@ import React, { useLayoutEffect, useState } from "react";
function DownloadText({ text, children, ...restProps }) {
const [url, setUrl] = useState(null);
useLayoutEffect(() => {
let blob = new Blob([text], {
var blob = new Blob([text], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);

View file

@ -1,7 +1,6 @@
export const SCREENSHOT_WIDTH = 275;
export const SCREENSHOT_HEIGHT = 348;
export const SKIN_RATIO = SCREENSHOT_HEIGHT / SCREENSHOT_WIDTH;
export const MOBILE_MAX_WIDTH = "56.25vh"; // 9:16 aspect ratio (100vh * 9/16) for TikTok-style scroll
export const ABOUT_PAGE = "ABOUT_PAGE";
export const UPLOAD_PAGE = "UPLOAD_PAGE";
export const REVIEW_PAGE = "REVIEW_PAGE";
@ -33,8 +32,9 @@ export const SKIN_CDN = R2_CDN;
// export const SCREENSHOT_CDN = "https://cdn.webampskins.org";
// export const SKIN_CDN = "https://cdn.webampskins.org";
// Sites have been unified, we can point to ourselves now
export const API_URL = "https://skins.webamp.org";
// Note: This is a Cloudflare proxy for api.webamp.org which
// provides some additional caching.
export const API_URL = "https://api.webampskins.org";
// export const API_URL = "https://dev.webamp.org";
export const HEADING_HEIGHT = 46;
export const CHUNK_SIZE = 300;

View file

@ -1,7 +1,7 @@
import SparkMD5 from "spark-md5";
export function hashFile(file) {
let blobSlice =
var blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
@ -26,7 +26,7 @@ export function hashFile(file) {
fileReader.onerror = reject;
function loadNext() {
let start = currentChunk * chunkSize,
var start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));

View file

@ -218,7 +218,7 @@ const unloadedSkinEpic = (actions, _states) =>
count
nodes {
md5
filename(normalize_extension: true)
filename
nsfw
}
}
@ -462,10 +462,7 @@ const urlEpic = (actions, state) => {
const newUrl = proposedUrl.toString();
// Avoid clobbering URL for beta site
if (!window.location.href.includes("/scroll")) {
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
}
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
}),
ignoreElements()
);
@ -484,7 +481,7 @@ const skinDataEpic = (actions, state) => {
const QUERY = gql`
query IndividualSkin($md5: String!) {
fetch_skin_by_md5(md5: $md5) {
filename(normalize_extension: true)
filename
nsfw
}
}

View file

@ -19,7 +19,6 @@ function Row({ name, loading, right, complete }) {
position: "absolute",
left: 0,
top: 0,
// eslint-disable-next-line no-nested-ternary -- legacy code
width: loading ? `90%` : complete ? `100%` : `0%`,
transitionProperty: "all",
// TODO: Try to learn how long it really takes

View file

@ -22,9 +22,7 @@ export async function upload(fileObj) {
console.warn(
`Request to ${uploadUrl} returned 503, going to retry again in 5 seconds. ${retries} retries left...`
);
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
await new Promise((resolve) => setTimeout(resolve, 5000));
continue;
}

View file

@ -13,7 +13,7 @@ export function museumUrlFromHash(hash) {
}
export function getWindowSize() {
let w = window,
var w = window,
d = document,
e = d.documentElement,
g = d.getElementsByTagName("body")[0],

View file

@ -1,16 +0,0 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
await knex.raw(
`CREATE TABLE "bluesky_posts" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"skin_md5" TEXT NOT NULL,
post_id text NOT NULL UNIQUE,
url text NOT NULL UNIQUE
);`
);
}
export async function down(knex: Knex): Promise<any> {
await knex.raw(`DROP TABLE "bluesky_posts"`);
}

View file

@ -1,21 +0,0 @@
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;
`);
}

View file

@ -1,36 +0,0 @@
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;
`);
}

View file

@ -1,16 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
// !! WARN !!
ignoreBuildErrors: true,
},
serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"],
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;

View file

@ -4,7 +4,6 @@
"main": "index.js",
"license": "MIT",
"dependencies": {
"@atproto/api": "^0.17.2",
"@next/third-parties": "^15.3.3",
"@sentry/node": "^5.27.3",
"@sentry/tracing": "^5.27.3",
@ -25,10 +24,9 @@
"jszip": "^3.10.1",
"knex": "^0.21.1",
"lru-cache": "^6.0.0",
"lucide-react": "^0.553.0",
"mastodon-api": "^1.3.0",
"md5": "^2.2.1",
"next": "^15.3.6",
"next": "^15.3.3",
"node-fetch": "^2.6.7",
"openai": "^4.68.0",
"polygon-clipping": "^0.15.3",
@ -48,8 +46,7 @@
"ts-node": "^10.5.0",
"twit": "^2.2.11",
"winston": "^3.2.1",
"yargs": "^13.2.4",
"webamp": "workspace:*"
"yargs": "^13.2.4"
},
"scripts": {
"lint": "eslint .",
@ -76,10 +73,9 @@
"@types/jest": "^30.0.0",
"@types/lru-cache": "^5.1.0",
"@types/node-fetch": "^2.5.7",
"@types/react": "^19.1.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"grats": "0.0.0-main-e655d1ae",
"grats": "^0.0.31",
"typescript": "^5.6.2"
},
"jest": {

View file

@ -1,304 +0,0 @@
/**
* Simple Blue/Green Deployment Script
* This script handles deploying to the inactive instance and switching traffic
*
* Usage: npx tsx scripts/deploy.ts
*/
import { execSync } from "child_process";
import { readFileSync, copyFileSync } from "fs";
import * as readline from "readline";
// ANSI color codes
const colors = {
red: "\x1b[0;31m",
green: "\x1b[0;32m",
blue: "\x1b[0;34m",
cyan: "\x1b[0;36m",
yellow: "\x1b[1;33m",
bold: "\x1b[1m",
reset: "\x1b[0m",
} as const;
// Configuration
const APACHE_CONFIG = "/etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf";
const BLUE_PORT = 3001;
const GREEN_PORT = 3002;
type Color = "blue" | "green";
interface DeploymentState {
currentColor: Color;
currentPort: number;
newColor: Color;
newPort: number;
}
function log(message: string, color?: keyof typeof colors): void {
if (color) {
console.log(`${colors[color]}${message}${colors.reset}`);
} else {
console.log(message);
}
}
function logBlank(): void {
console.log();
}
function exec(command: string, description: string): void {
try {
execSync(command, { stdio: "inherit", shell: "/bin/bash" });
} catch (error) {
log(`✗ Failed to ${description}`, "red");
throw error;
}
}
function execSilent(command: string): string {
return execSync(command, { encoding: "utf8" });
}
function detectCurrentDeployment(): DeploymentState {
log("→ Detecting current active deployment...", "cyan");
const apacheConfig = readFileSync(APACHE_CONFIG, "utf8");
const isBlueActive = apacheConfig.includes(`localhost:${BLUE_PORT}`);
if (isBlueActive) {
log(
` Current active: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
);
log(
` Deploying to: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
);
logBlank();
return {
currentColor: "blue",
currentPort: BLUE_PORT,
newColor: "green",
newPort: GREEN_PORT,
};
} else {
log(
` Current active: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
);
log(
` Deploying to: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
);
logBlank();
return {
currentColor: "green",
currentPort: GREEN_PORT,
newColor: "blue",
newPort: BLUE_PORT,
};
}
}
async function promptForConfirmation(
newPort: number,
newColor: Color
): Promise<boolean> {
const colorCode = newColor === "blue" ? colors.blue : colors.green;
log("========================================", "cyan");
log(" MANUAL VALIDATION REQUIRED", "yellow");
log("========================================", "cyan");
logBlank();
log(` Test the new ${colorCode}${newColor}${colors.reset} deployment at:`);
log(` ${colors.bold}https://${newColor}.api.webamp.org${colors.reset}`);
logBlank();
log(` You can test it with:`);
log(
` ${colors.cyan}curl -I https://${newColor}.api.webamp.org${colors.reset}`
);
logBlank();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(" Does everything look good? (yes/no): ", (answer) => {
rl.close();
logBlank();
resolve(answer.toLowerCase() === "yes");
});
});
}
function switchApacheConfig(state: DeploymentState): void {
const colorCode = state.newColor === "blue" ? colors.blue : colors.green;
log(
`→ Switching production to ${colorCode}${state.newColor}${colors.reset}...`,
"cyan"
);
log(" Updating Apache configuration...", "cyan");
// Backup current config
const backupPath = `${APACHE_CONFIG}.backup`;
copyFileSync(APACHE_CONFIG, backupPath);
// Update the port in Apache config
exec(
`sudo sed -i 's/localhost:${state.currentPort}/localhost:${state.newPort}/g' "${APACHE_CONFIG}"`,
"update Apache configuration"
);
// Reload Apache
exec("sudo systemctl reload apache2", "reload Apache");
log("✓ Apache configuration updated and reloaded", "cyan");
logBlank();
// Verify the change
const updatedConfig = readFileSync(APACHE_CONFIG, "utf8");
if (!updatedConfig.includes(`localhost:${state.newPort}`)) {
throw new Error("Configuration update verification failed");
}
}
function restoreBackup(): void {
log(" Restoring backup...", "yellow");
const backupPath = `${APACHE_CONFIG}.backup`;
exec(`sudo cp "${backupPath}" "${APACHE_CONFIG}"`, "restore backup");
exec("sudo systemctl reload apache2", "reload Apache");
log("✓ Backup restored", "yellow");
}
async function main(): Promise<void> {
try {
log("========================================", "cyan");
log(" Blue/Green Deployment Script", "cyan");
log("========================================", "cyan");
logBlank();
// Step 1: Detect current deployment
const state = detectCurrentDeployment();
// Step 2: Pull from GitHub
log("→ Pulling latest code from GitHub...", "cyan");
exec("git pull --rebase origin master", "pull from GitHub");
log("✓ Code updated", "cyan");
logBlank();
// Step 3: Ensure correct Node version
log("→ Ensuring correct Node version...", "cyan");
exec(
"source ~/.nvm/nvm.sh && nvm install",
"ensure correct Node version from .nvmrc"
);
log("✓ Node version verified", "cyan");
logBlank();
// Step 4: Install dependencies
log("→ Installing dependencies...", "cyan");
exec(
"source ~/.nvm/nvm.sh && nvm exec yarn install --frozen-lockfile",
"install dependencies"
);
log("✓ Dependencies installed", "cyan");
logBlank();
// Step 5: Build the site
log("→ Building the site...", "cyan");
exec("source ~/.nvm/nvm.sh && nvm exec yarn build", "build the site");
log("✓ Build complete", "cyan");
logBlank();
// Step 5: Deploy to inactive instance
const newColorCode = state.newColor === "blue" ? colors.blue : colors.green;
log(
`→ Restarting ${newColorCode}${state.newColor}${colors.reset} instance...`,
"cyan"
);
exec(
`pm2 restart skin-database-${state.newColor}`,
`restart ${state.newColor} instance`
);
log(
`${newColorCode}${state.newColor}${colors.reset} instance restarted`,
"cyan"
);
logBlank();
// Wait for the service to start
log("→ Waiting for service to be ready...", "cyan");
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
// Check if the service is running
const pm2List = execSilent("pm2 list");
const isRunning =
pm2List.includes(`skin-database-${state.newColor}`) &&
pm2List.includes("online");
if (isRunning) {
log(
`${newColorCode}${state.newColor}${colors.reset} instance is running`,
"cyan"
);
} else {
log(
`${newColorCode}${state.newColor}${colors.reset} instance failed to start!`,
"red"
);
log(` Check PM2 logs: pm2 logs skin-database-${state.newColor}`, "red");
process.exit(1);
}
logBlank();
// Step 6: Manual validation prompt
const confirmed = await promptForConfirmation(
state.newPort,
state.newColor
);
if (!confirmed) {
log("✗ Deployment cancelled!", "red");
log(
` The ${newColorCode}${state.newColor}${colors.reset} instance is running but not active in production.`
);
log(
` You can rollback by restarting: pm2 restart skin-database-${state.newColor}`
);
process.exit(1);
}
// Step 7: Switch Apache configuration
switchApacheConfig(state);
// Success message
const currentColorCode =
state.currentColor === "blue" ? colors.blue : colors.green;
log("========================================", "cyan");
log(" DEPLOYMENT SUCCESSFUL!", "cyan");
log("========================================", "cyan");
logBlank();
log(
` Active deployment: ${newColorCode}${state.newColor}${colors.reset} (port ${state.newPort})`
);
log(
` Previous deployment: ${currentColorCode}${state.currentColor}${colors.reset} (port ${state.currentPort}) - still running as backup`
);
logBlank();
log("Note: If you need to rollback:", "yellow");
log(` 1. Edit: ${APACHE_CONFIG}`);
log(` 2. Change port back to ${state.currentPort}`);
log(` 3. Run: sudo systemctl reload apache2`);
} catch (error) {
if (
error instanceof Error &&
error.message === "Configuration update verification failed"
) {
log("✗ Configuration update failed!", "red");
restoreBackup();
}
process.exit(1);
}
}
// Run the deployment
main();

View file

@ -7,10 +7,10 @@ source "$NVM_DIR/nvm.sh"
nvm use 20
# Install dependencies
pnpm install --frozen-lockfile
yarn install --frozen-lockfile
# Build the site
pnpm run build
yarn run build
# Reload processes via PM2
pm2 reload ecosystem.config.js

View file

@ -1,16 +1,5 @@
import fetch from "node-fetch";
import { execFile } from "../utils";
import path from "path";
// Path to the ia command in the virtual environment
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
// Environment variables for the virtual environment
const getVenvEnv = () => ({
...process.env,
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
});
import { exec } from "../utils";
export async function fetchMetadata(identifier: string): Promise<any> {
const r = await fetch(`https://archive.org/metadata/${identifier}`);
@ -22,8 +11,9 @@ export async function fetchMetadata(identifier: string): Promise<any> {
}
export async function fetchTasks(identifier: string): Promise<any> {
const result = await execFile(IA_COMMAND, ["tasks", identifier], {
env: getVenvEnv(),
const command = `ia tasks ${identifier}`;
const result = await exec(command, {
encoding: "utf8",
});
return result.stdout
.trim()
@ -35,31 +25,12 @@ export async function uploadFile(
identifier: string,
filepath: string
): Promise<any> {
await execFile(IA_COMMAND, ["upload", identifier, filepath], {
env: getVenvEnv(),
});
}
export async function uploadFiles(
identifier: string,
filepaths: string[],
metadata?: { [key: string]: string }
): Promise<any> {
const args = ["upload", identifier, ...filepaths];
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
args.push(`--metadata=${key}:${value}`);
});
}
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
const command = `ia upload ${identifier} "${filepath}"`;
await exec(command, { encoding: "utf8" });
}
export async function identifierExists(identifier: string): Promise<boolean> {
const result = await execFile(IA_COMMAND, ["metadata", identifier], {
env: getVenvEnv(),
});
const result = await exec(`ia metadata ${identifier}`);
const data = JSON.parse(result.stdout);
return Object.keys(data).length > 0;
}
@ -69,10 +40,7 @@ export async function setMetadata(
data: { [key: string]: string }
) {
const pairs = Object.entries(data).map(([key, value]) => `${key}:${value}`);
const args = [
"metadata",
identifier,
...pairs.map((pair) => `--modify=${pair}`),
];
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
const args = pairs.map((pair) => `--modify="${pair}"`);
const command = `ia metadata ${identifier} ${args.join(" ")}`;
await exec(command);
}

View file

@ -1,172 +0,0 @@
import * as Skins from "../data/skins";
import {
AppBskyEmbedImages,
AppBskyFeedPost,
AtpAgent,
BlobRef,
RichText,
} from "@atproto/api";
import {
TWEET_BOT_CHANNEL_ID,
BLUESKY_USERNAME,
BLUESKY_PASSWORD,
} from "../config";
import { Client } from "discord.js";
import sharp from "sharp";
import SkinModel from "../data/SkinModel";
import UserContext from "../data/UserContext";
import { withBufferAsTempFile } from "../utils";
import fs from "fs";
const agent = new AtpAgent({ service: "https://bsky.social" });
export async function postToBluesky(
discordClient: Client,
md5: string | null
): Promise<void> {
if (md5 == null) {
md5 = await Skins.getSkinToPostToBluesky();
}
if (md5 == null) {
console.error("No skins to post to Bluesky");
return;
}
const url = await post(md5);
console.log("Going to post to discord");
const tweetBotChannel = await discordClient.channels.fetch(
TWEET_BOT_CHANNEL_ID
);
// @ts-ignore
await tweetBotChannel.send(url);
console.log("Posted to discord");
}
async function post(md5: string): Promise<string> {
const ctx = new UserContext();
const skin = await SkinModel.fromMd5Assert(ctx, md5);
const screenshot = await Skins.getScreenshotBuffer(md5);
const { width, height } = await sharp(screenshot).metadata();
const image = await sharp(screenshot)
.resize(width * 2, height * 2, {
kernel: sharp.kernel.nearest,
})
.toBuffer();
const name = await skin.getFileName();
const url = skin.getMuseumUrl();
const screenshotFileName = await skin.getScreenshotFileName();
const status = `${name}\n`; // TODO: Should we add hashtags?
await agent.login({
identifier: BLUESKY_USERNAME!,
password: BLUESKY_PASSWORD!,
});
const blob = await withBufferAsTempFile(
image,
screenshotFileName,
async (filePath) => {
return uploadImageFromFilePath(agent, filePath);
}
);
const postData = await buildPost(
agent,
status,
buildImageEmbed(blob, width * 2, height * 2)
);
const postResp = await agent.post(postData);
console.log(postResp);
const postId = postResp.cid;
const postUrl = postResp.uri;
await Skins.markAsPostedToBlueSky(md5, postId, postUrl);
const prefix = "Try on the ";
const suffix = "Winamp Skin Museum";
agent.post({
text: prefix + suffix,
createdAt: new Date().toISOString(),
facets: [
{
$type: "app.bsky.richtext.facet",
index: {
byteStart: prefix.length,
byteEnd: prefix.length + suffix.length,
},
features: [
{
$type: "app.bsky.richtext.facet#link",
uri: url,
},
],
},
],
reply: {
root: postResp,
parent: postResp,
},
$type: "app.bsky.feed.post",
});
// return permalink;
return postUrl;
}
/** Build the embed data for an image. */
function buildImageEmbed(
imgBlob: BlobRef,
width: number,
height: number
): AppBskyEmbedImages.Main {
const image = {
image: imgBlob,
aspectRatio: { width, height },
alt: "",
};
return {
$type: "app.bsky.embed.images",
images: [image],
};
}
/** Build the post data for an image. */
async function buildPost(
agent: AtpAgent,
rawText: string,
imageEmbed: AppBskyEmbedImages.Main
): Promise<AppBskyFeedPost.Record> {
const rt = new RichText({ text: rawText });
await rt.detectFacets(agent);
const { text, facets } = rt;
return {
text,
facets,
$type: "app.bsky.feed.post",
createdAt: new Date().toISOString(),
embed: {
$type: "app.bsky.embed.recordWithMedia",
...imageEmbed,
},
};
}
/** Upload an image from a URL to Bluesky. */
async function uploadImageFromFilePath(
agent: AtpAgent,
filePath: string
): Promise<BlobRef> {
const imageBuff = fs.readFileSync(filePath);
const imgU8 = new Uint8Array(imageBuff);
const dstResp = await agent.uploadBlob(imgU8);
if (!dstResp.success) {
throw new Error("Failed to upload image");
}
return dstResp.data.blob;
}

View file

@ -1,181 +0,0 @@
import { knex } from "../db";
import type { UserEvent } from "../app/(modern)/scroll/Events";
const WEIGHTS = {
viewDurationPerSecond: 0.1,
like: 10,
download: 5,
share: 15,
readmeExpand: 2,
};
interface SessionAggregate {
skinViewDurations: Map<string, number>;
skinsLiked: Set<string>;
skinsDownloaded: Set<string>;
readmesExpanded: Set<string>;
sharesSucceeded: Set<string>;
}
interface SkinRanking {
skinMd5: string;
totalViewDurationMs: number;
viewCount: number;
averageViewDurationMs: number;
likeCount: number;
downloadCount: number;
shareCount: number;
readmeExpandCount: number;
rankingScore: number;
}
async function main() {
try {
const rankings = await computeSkinRankings();
console.log(JSON.stringify(rankings, null, 2));
} catch (error) {
console.error("Error during aggregation:", error);
throw error;
} finally {
await knex.destroy();
}
}
if (require.main === module) {
main();
}
export async function computeSkinRankings(): Promise<SkinRanking[]> {
const sessionMap = await buildSessionAggregates();
const skinDataMap = new Map<
string,
{
viewDurations: number[];
likes: number;
downloads: number;
shares: number;
readmeExpands: number;
}
>();
function getSkinData(skinMd5: string) {
if (!skinDataMap.has(skinMd5)) {
skinDataMap.set(skinMd5, {
viewDurations: [],
likes: 0,
downloads: 0,
shares: 0,
readmeExpands: 0,
});
}
return skinDataMap.get(skinMd5)!;
}
for (const session of sessionMap.values()) {
for (const [skinMd5, duration] of session.skinViewDurations) {
getSkinData(skinMd5).viewDurations.push(duration);
}
for (const skinMd5 of session.skinsLiked) {
getSkinData(skinMd5).likes++;
}
for (const skinMd5 of session.skinsDownloaded) {
getSkinData(skinMd5).downloads++;
}
for (const skinMd5 of session.sharesSucceeded) {
getSkinData(skinMd5).shares++;
}
for (const skinMd5 of session.readmesExpanded) {
getSkinData(skinMd5).readmeExpands++;
}
}
const rankings: SkinRanking[] = [];
for (const [skinMd5, data] of skinDataMap) {
const totalViewDurationMs = data.viewDurations.reduce(
(sum, duration) => sum + duration,
0
);
const viewCount = data.viewDurations.length;
const averageViewDurationMs =
viewCount > 0 ? totalViewDurationMs / viewCount : 0;
const rankingScore =
(averageViewDurationMs / 1000) * WEIGHTS.viewDurationPerSecond +
data.likes * WEIGHTS.like +
data.downloads * WEIGHTS.download +
data.shares * WEIGHTS.share +
data.readmeExpands * WEIGHTS.readmeExpand;
rankings.push({
skinMd5,
totalViewDurationMs,
viewCount,
averageViewDurationMs,
likeCount: data.likes,
downloadCount: data.downloads,
shareCount: data.shares,
readmeExpandCount: data.readmeExpands,
rankingScore,
});
}
rankings.sort((a, b) => b.rankingScore - a.rankingScore);
return rankings;
}
async function buildSessionAggregates(): Promise<
Map<string, SessionAggregate>
> {
const events = await knex("user_log_events")
.select("session_id", "timestamp", "metadata")
.orderBy("timestamp", "asc");
const sessionMap = new Map<string, SessionAggregate>();
function getSession(sessionId: string): SessionAggregate {
if (!sessionMap.has(sessionId)) {
sessionMap.set(sessionId, {
skinViewDurations: new Map(),
skinsLiked: new Set(),
skinsDownloaded: new Set(),
readmesExpanded: new Set(),
sharesSucceeded: new Set(),
});
}
return sessionMap.get(sessionId)!;
}
for (const row of events) {
const event: UserEvent = JSON.parse(row.metadata);
const session = getSession(row.session_id);
switch (event.type) {
case "skin_view_end":
session.skinViewDurations.set(event.skinMd5, event.durationMs);
break;
case "readme_expand":
session.readmesExpanded.add(event.skinMd5);
break;
case "skin_download":
session.skinsDownloaded.add(event.skinMd5);
break;
case "skin_like":
if (event.liked) {
session.skinsLiked.add(event.skinMd5);
} else {
session.skinsLiked.delete(event.skinMd5);
}
break;
case "share_success":
session.sharesSucceeded.add(event.skinMd5);
break;
}
}
return sessionMap;
}
export { buildSessionAggregates };
export type { SessionAggregate };

View file

@ -7,7 +7,6 @@ import * as Skins from "../data/skins";
import * as CloudFlare from "../CloudFlare";
import SkinModel from "../data/SkinModel";
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-require-imports
const Shooter = require("../shooter");
export async function screenshot(skin: SkinModel, shooter: typeof Shooter) {

View file

@ -8,7 +8,7 @@ import SkinModel from "../data/SkinModel";
import * as Parallel from "async-parallel";
import IaItemModel from "../data/IaItemModel";
import DiscordEventHandler from "../api/DiscordEventHandler";
import { execFile } from "../utils";
import { exec } from "../utils";
import * as IAService from "../services/internetArchive";
export async function findItemsMissingImages(): Promise<string[]> {
@ -192,7 +192,7 @@ async function getNewIdentifier(filename: string): Promise<string> {
}
export async function archive(skin: SkinModel): Promise<string> {
const filename = await skin.getFileName(true);
const filename = await skin.getFileName();
const screenshotFilename = await skin.getScreenshotFileName();
const title = `Winamp Skin: ${filename}`;
@ -207,36 +207,8 @@ export async function archive(skin: SkinModel): Promise<string> {
console.log(`Going to try to upload with identifier "${identifier}"...`);
// Path to the ia command in the virtual environment
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
// Environment variables for the virtual environment
const venvEnv = {
...process.env,
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
};
const metadata = {
collection: "winampskins",
skintype: "wsz",
mediatype: "software",
title: title,
};
// Build arguments array for ia upload command
const args = [
"upload",
identifier,
skinFile,
screenshotFile,
`--metadata=collection:${metadata.collection}`,
`--metadata=skintype:${metadata.skintype}`,
`--metadata=mediatype:${metadata.mediatype}`,
`--metadata=title:${metadata.title}`,
];
await execFile(IA_COMMAND, args, { env: venvEnv });
const command = `ia upload ${identifier} "${skinFile}" "${screenshotFile}" --metadata="collection:winampskins" --metadata="skintype:wsz" --metadata="mediatype:software" --metadata="title:${title}"`;
await exec(command, { encoding: "utf8" });
await knex("ia_items").insert({ skin_md5: skin.getMd5(), identifier });
return identifier;
}

View file

@ -23,7 +23,11 @@
"outDir": "dist",
"strictNullChecks": true,
"skipLibCheck": true,
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"strict": false,
"noEmit": true,
"incremental": true,
@ -33,9 +37,13 @@
},
"include": [
"./**/*",
".next/types/**/*.ts",
".next/types/app/(legacy)/about/page.tsx"
".next/types/**/*.ts"
],
"lib": ["es2015"],
"exclude": ["node_modules", "dist"]
"lib": [
"es2015"
],
"exclude": [
"node_modules",
"dist"
]
}

View file

@ -19,7 +19,7 @@ export type SkinRow = {
skin_type: number;
emails: string;
// readme_text: string;
average_color?: string;
average_color: string;
};
export type TweetRow = {

View file

@ -6,7 +6,6 @@ import child_process from "child_process";
import path from "path";
export const exec = util.promisify(child_process.exec);
export const execFile = util.promisify(child_process.execFile);
const temp = _temp.track();

View file

@ -5,7 +5,9 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
First, run the development server:
```bash
pnpm dev
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

View file

@ -11,8 +11,8 @@
"dependencies": {
"@vercel/og": "^0.0.20",
"next": "13.0.3",
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"vercel": "^28.4.17"

View file

@ -98,10 +98,11 @@ async function searchImage(query) {
});
}
function HeaderGrid({ skins, _title }) {
function HeaderGrid({ skins, title }) {
return (
<div
style={{
background: "black",
color: "white",
width: "100%",
height: "100%",
@ -109,6 +110,7 @@ function HeaderGrid({ skins, _title }) {
flexDirection: "column",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
justifyContent: "space-between",
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
}}

View file

@ -2,6 +2,7 @@ export default function Frame({ children }) {
return (
<div
style={{
background: "black",
color: "white",
width: "100%",
height: "100%",
@ -9,6 +10,7 @@ export default function Frame({ children }) {
flexDirection: "column",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
justifyContent: "space-between",
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
}}

View file

@ -15,7 +15,7 @@ npm install --save webamp
From here you can import Webamp in your JavaScript code:
```js
import Webamp from "webamp/butterchurn";
import Webamp from "webamp";
// ... use Webamp here
```
@ -25,7 +25,19 @@ ES modules can be imported via URL directly inside a `<script type="module">` ta
```html
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
import Webamp from "https://unpkg.com/webamp@^2";
// ... use Webamp here
</script>
```
## Include via a script tag
This will make the Webamp constructor available as a `window` property: `window.Webamp` keep in mind that you will need to use the `type="module"` attribute on the script tag,
```html
<script src="https://unpkg.com/webamp@^2" type="module"></script>
<script>
const Webamp = window.Webamp;
// ... use Webamp here
</script>
```

View file

@ -12,8 +12,6 @@ Create a DOM element somewhere in your HTML document. This will be used by Webam
:::tip
**Webamp will not actually insert itself as a child of this element.** It will will insert itself as a child of the body element, and will attempt to center itself within this element. This is needed to allow the various Webamp windows to dragged around the page unencumbered.
If you want Webamp to be a child of a specific element, use the [`renderInto(domNode)`](./06_API/03_instance-methods.md#renderintodomnode-htmlelement-promisevoid) method instead _(available in unreleased version)_. Note that the target element must have a non-static CSS position (e.g., `position: relative`) for this to work correctly.
:::
## Initialize Webamp instance

View file

@ -2,7 +2,7 @@
Webamp uses [Butterchurn](https://butterchurnviz.com/) to provide a Milkdrop visualizer. Butterchurn is a JavaScript port of the original Milkdrop visualizer, and it can run in any modern web browser.
Starting with [v2.2.0](../12_changelog.md#220) Webamp includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled. See "Minimal Milkdrop" in [examples](../04_examples.md) for more details.
The [next release](../12_changelog.md#unreleased) of Webamp will includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled.
## Hotkeys

Some files were not shown because too many files have changed in this diff Show more