Compare commits

..

No commits in common. "master" and "v0.0.0-next-87012d8d" have entirely different histories.

432 changed files with 269167 additions and 36291 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

@ -1,125 +1,84 @@
name: CI
on:
push:
pull_request:
on: [push]
jobs:
# Main CI job - using Turborepo for dependency management
ci:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
version: 9.12.0
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "pnpm"
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build all packages
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build
run: |
npx turbo build build-library
yarn workspace ani-cursor build
yarn workspace webamp build
yarn workspace webamp build-library
- name: Lint
run: |
yarn lint
yarn workspace webamp type-check
- name: Run Unit Tests
run: |
touch packages/skin-database/config.js
yarn test
yarn workspace webamp test
- name: Run Integration Tests
run: yarn workspace webamp integration-tests
env:
NODE_ENV: production
- name: Lint and type-check
run: |
npx turbo lint type-check
- name: Validate Grats generated files are up-to-date
run: ./scripts/validate-grats.sh
- name: Run tests
run: |
npx turbo test -- --maxWorkers=2
env:
NODE_ENV: test
- name: Cache build artifacts for release
uses: actions/cache@v4
CI: true
- name: Upload Screenshot Diffs
if: failure()
uses: actions/upload-artifact@v4
with:
path: |
packages/ani-cursor/dist
packages/winamp-eqf/built
packages/webamp/built
key: release-artifacts-${{ github.sha }}
# Release job - publish packages to NPM
release:
name: Publish packages to NPM
name: image_diffs
path: packages/webamp/js/__tests__/__image_snapshots__/__diff_output__/
- name: Generate New Screenshots
if: failure()
run: |
yarn workspace webamp integration-tests -u
- name: Upload New Screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: new_images
path: packages/webamp/js/__tests__/__image_snapshots__/
main-release:
name: Publish to NPM
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
needs: [build-and-test]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.0
- uses: actions/setup-node@v4
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20.x
registry-url: https://registry.npmjs.org/
cache: "pnpm"
- name: Update npm to latest version
run: npm install -g npm@latest
cache: "yarn"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore build artifacts
uses: actions/cache@v4
with:
path: |
packages/ani-cursor/dist
packages/winamp-eqf/built
packages/webamp/built
key: release-artifacts-${{ github.sha }}
fail-on-cache-miss: true
- name: Set version for all packages
if: github.ref == 'refs/heads/master'
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build latest (main) version
if: github.ref == 'refs/heads/main'
run: |
echo "Setting version to 0.0.0-next-${RELEASE_COMMIT_SHA::7}"
cd packages/webamp && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
cd ../ani-cursor && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
yarn workspace webamp build-library
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: exit 1 # TODO: Script to update version number in webampLazy.tsx
- name: Publish to npm
if: github.ref == 'refs/heads/main' || 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
- 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'))
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
- 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'))
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
- name: Publish webamp to npm
working-directory: ./packages/webamp
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 webamp ${TAG}
env:
TAG: ${{ github.ref == 'refs/heads/main' && '--tag=main' || ((contains(github.ref_name, '-rc.') && '--tag=dev') || '' )}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

View file

@ -4,23 +4,13 @@ on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.0
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "deploy"
pattern: "./packages/webamp/built/*bundle.min.js"
- uses: actions/checkout@v2
- uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "deploy"
pattern: "./packages/webamp/built/*bundle.min.js"

View file

@ -0,0 +1,29 @@
name: "Internet Archive Integration Tests"
on:
workflow_dispatch:
# push:
# schedule:
# - cron: "0 8 * * *"
jobs:
ia-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Run Tests
run: |
cd packages/archive-org-webamp-integration-tests
yarn
node ./index.js
env:
CI: true
- uses: actions/upload-artifact@v1
if: failure()
with:
name: error
path: packages/webamp/experiments/archive-org-integration-tests/error.png

6
.gitignore vendored
View file

@ -1,7 +1,5 @@
node_modules
.vscode
.idea
.parcel-cache
dist
# Turborepo cache
.turbo
parcel-bundle-reports

1
.nvmrc
View file

@ -1 +0,0 @@
22

3
.parcelrc Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "@parcel/config-default"
}

147392
.yarn/releases/yarn-1.22.10.cjs vendored Executable file

File diff suppressed because one or more lines are too long

5
.yarnrc Normal file
View file

@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.22.10.cjs"

View file

@ -1,31 +1,45 @@
[![gzip size](https://img.badgesize.io/https:/unpkg.com/webamp/built/webamp.lazy-bundle.min.js?label=gzip&compression=gzip)](https://bundlephobia.com/result?p=webamp)
[![Tests](https://github.com/captbaritone/webamp/workflows/CI/badge.svg)](https://github.com/captbaritone/webamp/actions?query=branch%3Amaster+workflow%3ACI)
[![Discord](https://img.shields.io/discord/434058775012311061.svg)](https://webamp.org/chat)
# Webamp
A reimplementation of Winamp in HTML5 and JavaScript with full skin support.
A reimplementation of Winamp 2.9 in HTML5 and JavaScript with full skin support.
As seen on [TechCrunch], [Motherboard], [Gizmodo], Hacker News ([1], [2], [3], [4]), and [elsewhere](./packages/webamp/docs/press.md).
[![Screenshot of Webamp](https://raw.githubusercontent.com/captbaritone/webamp/master/packages/webamp/demo/images/preview.png)](https://webamp.org)
Check out this [Twitter thread](https://twitter.com/captbaritone/status/961274714013319168) for an illustrated list of features. Works in modern versions of Edge, Firefox, Safari and Chrome. IE is [not supported](http://caniuse.com/#feat=audio-api).
## Read the docs
## Add Webamp to Your Site
**The [Webamp Documentation](https://docs.webamp.org) site contains detailed instructions showing how to add Webamp to your site and customize it to meet your needs.**
Here is the **most minimal** example of adding Webamp to a page:
```HTML
<div id="app"></div>
<script src="https://unpkg.com/webamp"></script>
<script>
const app = document.getElementById("app")
const webamp = new Webamp();
webamp.renderWhenReady(app);
</script>
```
For more examples, including how to add audio files, check out [`examples/` directory](./examples) and the [API documentation](./packages/webamp/docs/usage.md).
## About This Repository
Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in addition to the Webamp NPM module, this repository contains code for a few closely related projects and some pieces of Webamp which are published as standalone modules:
- [`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/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
* [`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/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/archive-org-webamp-integration-tests`](https://github.com/captbaritone/webamp/tree/master/packages/archive-org-webamp-integration-tests): An integration that confirms that archive.org's Webamp integration is working as expected
* [`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
## Community
@ -65,52 +79,6 @@ Nullsoft, the code within this project is released under the [MIT
License](LICENSE.txt). That being said, if you do anything interesting with
this code, please let me know. I'd love to see it.
## Development
This repository uses [Turborepo](https://turbo.build/) for efficient monorepo management. Turborepo provides intelligent caching and parallel execution of tasks across all packages.
### Quick Start
```bash
# Install dependencies
pnpm install
# Build all packages (automatically handles dependencies)
npx turbo build
# Build library bundles for packages that need them
npx turbo build-library
# Run all tests
npx turbo test
# Lint and type-check all packages
npx turbo lint type-check
# Work on a specific package and its dependencies
npx turbo dev --filter="webamp"
```
### Package Dependencies
The monorepo dependency graph is automatically managed by Turborepo:
- `ani-cursor` and `winamp-eqf` are standalone packages built with TypeScript
- `webamp` depends on both `ani-cursor` and `winamp-eqf` for workspace linking
- All packages are built in the correct topological order
- Builds are cached and only rebuild what has changed
### Available Tasks
- `build` - Main build output (Vite for demos, TypeScript compilation for libraries)
- `build-library` - Library bundles for NPM publishing (only applies to `webamp`)
- `test` - Run unit tests with Jest
- `type-check` - TypeScript type checking without emitting files
- `lint` - ESLint code quality checks
- `dev` - Development server (for packages that support it)
For more details on individual packages, see their respective README files.
[techcrunch]: https://techcrunch.com/2018/02/09/whip-the-llamas-ass-with-this-javascript-winamp-emulator/
[motherboard]: https://motherboard.vice.com/en_us/article/qvebbv/winamp-2-mp3-music-player-emulator
[gizmodo]: https://gizmodo.com/winamp-2-has-been-immortalized-in-html5-for-your-pleasu-1655373653

View file

@ -8,8 +8,6 @@ module.exports = {
"dist",
// TODO: Add these as we can...
"/packages/webamp/",
"/packages/ani-cursor/",
"/packages/winamp-eqf/",
// TODO: Fix config import so that this can work.
"/packages/webamp-modern/src/__tests__/integration*",
],

6
deploy.sh Executable file
View file

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

View file

@ -1,117 +0,0 @@
# TypeScript Checking Convention
This document describes the TypeScript checking convention established for the Webamp monorepo.
## Current Status
Each TypeScript-enabled package in the monorepo now has a consistent `type-check` script that performs type checking without emitting files.
**Progress: 5 out of 6 packages now passing! 🎉**
### Package Status
#### ✅ Passing Packages
- **webamp**: Clean TypeScript compilation
- **ani-cursor**: Clean TypeScript compilation
- **skin-database**: Clean TypeScript compilation (fixed JSZip types, Jest types, and Buffer compatibility issues)
- **webamp-docs**: Clean TypeScript compilation
- **winamp-eqf**: Clean TypeScript compilation (ES module with full type definitions)
#### ❌ Failing Packages (Need fixes)
- **webamp-modern**: 390+ TypeScript errors (conflicting type definitions, target issues)
## Convention
### Package-level Scripts
Each TypeScript package should have:
```json
{
"scripts": {
"type-check": "tsc --noEmit"
}
}
```
**Important:** Always use `tsc --noEmit` to avoid accidentally creating JavaScript files in source directories.
### Root-level Script
The root package.json contains a centralized script that runs type checking for all currently passing packages:
```json
{
"scripts": {
"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"
}
}
```
### CI Integration
The CI workflow (`.github/workflows/ci.yml`) runs the centralized type-check command:
```yaml
- name: Lint
run: |
pnpm lint
pnpm type-check
```
## Adding New Packages
When adding a new TypeScript package to the type-check convention:
1. Add the `type-check` script to the package's `package.json`
2. Ensure the package passes type checking: `pnpm --filter <package-name> type-check`
3. Add the package to the root `type-check` script
4. Test the full suite: `pnpm type-check`
## Fixing Failing Packages
### Common Issues
1. **Missing Jest types** (Fixed in `skin-database`):
- Install `@types/jest` and configure proper Jest setup
- Ensure test files are properly configured
2. **Missing package types** (Fixed in `skin-database`):
- Install missing dependencies like `jszip`, `react-redux`, `express`
- Install corresponding `@types/` packages where needed
- Note: Some packages like JSZip provide their own types
3. **Buffer compatibility issues** (Fixed in `skin-database`):
- Newer TypeScript versions require explicit casting for `fs.writeFileSync`
- Use `new Uint8Array(buffer)` instead of raw `Buffer` objects
4. **Conflicting type definitions** (`webamp-modern`):
- Multiple versions of `@types/node` causing conflicts
- Target configuration issues (ES5 vs ES2015+)
- Dependency type mismatches
### Recommended Fix Strategy
1. Start with packages that have fewer errors
2. Focus on one category of errors at a time
3. Update TypeScript compiler target if needed (many errors require ES2015+)
4. Ensure proper dependency management to avoid type conflicts
## Benefits
- **Consistency**: All packages use the same type-checking approach
- **CI Integration**: Automatic type checking prevents type errors from being merged
- **Developer Experience**: Simple `pnpm type-check` command for full project validation
- **Incremental**: Only includes packages that currently pass, allowing gradual improvement
## Future Work
- Fix remaining packages to include them in the type-check suite
- Consider stricter TypeScript configurations for better type safety
- Investigate automated type checking in development workflow

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

@ -2,15 +2,15 @@
<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">
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
const webamp = new Webamp({
initialTracks: [
{
@ -20,7 +20,7 @@
},
// 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
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
@ -35,6 +35,7 @@
],
});
// Returns a promise indicating when it's done loading.
webamp.renderWhenReady(document.getElementById("app"));
</script>
</body>

View file

@ -2,7 +2,7 @@
## API is still being finalized and may change when released
This example fetches the Webamp bundle from a free CDN, and fetches the audio file from a free CDN as well, and loads Milkdrop visualizer.
This example fetches the Webamp bundle from a free CDN, and fetches the audio file and skin from a free CDN as well, and loads Milkdrop visualizer.
You should be able to open this local html file in your browser and see Webamp working.

View file

@ -2,20 +2,17 @@
<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">
/**
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
* entrypoint which includes the Butterchurn library to enable the
* Milkdrop visualizer.
*/
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
<script src="https://unpkg.com/webamp@1.5.0/built/webamp.bundle.min.js"></script>
<script src="https://unpkg.com/butterchurn@2.6.7/lib/butterchurn.min.js"></script>
<script src="https://unpkg.com/butterchurn-presets@2.4.7/lib/butterchurnPresets.min.js"></script>
<script>
const Webamp = window.Webamp;
const webamp = new Webamp({
initialTracks: [
{
@ -25,11 +22,36 @@
},
// 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
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
__butterchurnOptions: {
importButterchurn: () => Promise.resolve(window.butterchurn),
getPresets: () => {
const presets = window.butterchurnPresets.getPresets();
return Object.keys(presets).map((name) => {
return {
name,
butterchurnPresetObject: presets[name],
};
});
},
butterchurnOpen: true,
},
windowLayout: {
main: { position: { top: 0, left: 0 } },
equalizer: { position: { top: 116, left: 0 } },
playlist: {
position: { top: 232, left: 0 },
size: { extraWidth: 0, extraHeight: 4 },
},
milkdrop: {
position: { top: 0, left: 275 },
size: { extraHeight: 12, extraWidth: 7 },
},
},
});
webamp.renderWhenReady(document.getElementById("app"));
</script>

View file

@ -2,15 +2,15 @@
<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">
import Webamp from "https://unpkg.com/webamp@^2";
<script src="https://unpkg.com/webamp@0.0.0-next-6d0ec37b/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
const webamp = new Webamp({
windowLayout: {
main: {

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

@ -2,27 +2,22 @@
<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">
import Webamp from "https://unpkg.com/webamp@^2";
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
const webamp = new Webamp({
// Optional. An array of objects representing skins.
// These will appear in the "Options" menu under "Skins".
// NOTE: These URLs must be served with the correct CORs headers.
// https://docs.webamp.org/docs/guides/cors
// Note: These URLs must be served with the correct CORs headers.
//
// These will appear in the dropdown menu under "Skins".
availableSkins: [
{
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
name: "Zelda Amp",
},
{
url: "https://archive.org/cors/winampskin_Green-Dimension-V2/Green-Dimension-V2.wsz",
name: "Green Dimension V2",
@ -32,9 +27,6 @@
name: "Mac OSX v1.5 (Aqua)",
},
],
initialSkin: {
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
},
initialTracks: [
{
metaData: {

View file

@ -2,15 +2,15 @@
<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">
import Webamp from "https://unpkg.com/webamp@^2";
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
const webamp = new Webamp({
/**
* Here we list three tracks. Note that the `metaData` fields and
@ -26,7 +26,7 @@
},
// 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
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
@ -37,7 +37,7 @@
},
// 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
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
duration: 322.612245,
},
@ -48,7 +48,7 @@
},
// 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
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
duration: 190.093061,
},

View file

@ -0,0 +1,15 @@
# Minimal Example
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time.
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
To try it out:
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp/examples/webpack/
$ npm install
$ npm run build
$ open index.html
```

11
examples/webpack/index.html Executable file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="app" style="height: 100vh"></div>
<script src="./bundle.js"></script>
</body>
</html>

17
examples/webpack/index.js Normal file
View file

@ -0,0 +1,17 @@
import Webamp from "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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
}).renderWhenReady(document.getElementById("app"));

View file

@ -0,0 +1,19 @@
{
"name": "webamp-webpack-example",
"version": "0.0.0",
"description": "An example of using Webamp within a Webpack bundle",
"main": "index.js",
"scripts": {
"build": "webpack index.js -o bundle.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"webamp": "1.5.0"
},
"devDependencies": {
"webpack": "^4.29.6",
"webpack-cli": "^3.3.0"
},
"prettier": {}
}

View file

@ -0,0 +1,17 @@
# Minimal Lazy Loading Example
## API is still being finalized and may change when released
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time. Milkdrop and visualizer presets are lazy loaded after playing starts.
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
To try it out:
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp/examples/webpackLazyLoad/
$ npm install
$ npm run build
$ open index.html
```

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="app" style="height: 100vh"></div>
<script src="./bundle.js"></script>
</body>
</html>

View file

@ -0,0 +1,45 @@
import Webamp from "webamp";
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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
__butterchurnOptions: {
importButterchurn: () => {
// Only load butterchurn when music starts playing to reduce initial page load
return import("butterchurn");
},
getPresets: async () => {
// Load presets from preset URL mapping on demand as they are used
const resp = await fetch(
// NOTE: Your preset file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
"https://unpkg.com/butterchurn-presets-weekly@0.0.2/weeks/week1/presets.json"
);
const namesToPresetUrls = await resp.json();
return Object.keys(namesToPresetUrls).map((name) => {
return { name, butterchurnPresetUrl: namesToPresetUrls[name] };
});
},
butterchurnOpen: true,
},
__initialWindowLayout: {
main: { position: { x: 0, y: 0 } },
equalizer: { position: { x: 0, y: 116 } },
playlist: { position: { x: 0, y: 232 }, size: [0, 4] },
milkdrop: { position: { x: 275, y: 0 }, size: [7, 12] },
},
});
webamp.renderWhenReady(document.getElementById("app"));

View file

@ -0,0 +1,19 @@
{
"name": "webamp-webpack-lazy-example",
"version": "0.0.0",
"description": "An example of using Webamp within a Webpack bundle",
"main": "index.js",
"scripts": {
"build": "webpack index.js -o bundle.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"butterchurn": "^2.6.7",
"webamp": "1.5.0"
},
"devDependencies": {
"webpack": "^4.29.6",
"webpack-cli": "^3.3.0"
}
}

View file

@ -1,5 +1,5 @@
[build]
command = "pnpm run deploy"
command = "yarn deploy"
publish = "packages/webamp/dist/demo-site/"
# A short URL for listeners of https://changelog.com/podcast/291
@ -36,4 +36,4 @@ status = 301
force = true
[build.environment]
NODE_VERSION = "22.11.0"
NODE_VERSION = "20.9.0"

61959
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,52 +1,47 @@
{
"name": "webamp-monorepo",
"private": true,
"packageManager": "pnpm@9.12.0",
"overrides": {
"graphql": "16.8.1"
},
"workspaces": [
"packages/*",
"examples/*"
],
"engines": {
"node": ">=22.0.0"
"node": ">=16.0.0"
},
"scripts": {
"test": "npx turbo test",
"test:integration": "npx turbo run integration-tests",
"test:all": "npx turbo run test integration-tests",
"test:unit": "jest",
"lint": "npx turbo lint",
"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",
"test": "jest",
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
"deploy": "sh deploy.sh",
"format": "prettier --write '**/*.{js,ts,tsx}'"
},
"devDependencies": {
"@babel/preset-typescript": "^7.16.7",
"@parcel/optimizer-data-url": "2.7.0",
"@parcel/transformer-inline-string": "^2.8.2",
"@swc/core": "^1.3.24",
"@swc/jest": "^0.2.24",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"@typescript-eslint/parser": "^7.1.0",
"assert": "^2.0.0",
"eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.16.0",
"eslint-plugin-react-hooks": "^4.3.0",
"events": "^3.3.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest": "^27.5.1",
"prettier": "^2.3.2",
"puppeteer": "^22.2.0",
"stream-browserify": "^3.0.0",
"turbo": "^2.5.4",
"typescript": "^5.6.2"
"typescript": "^5.3.3"
},
"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"
}
}
"jest": {
"projects": [
"config/jest.*.js"
]
},
"dependencies": {},
"version": "0.0.0-next-87012d8d"
}

View file

@ -8,6 +8,12 @@ I wrote a blog post about this library which you can find [here](https://jordane
## Install
```bash
yarn add ani-cursor
```
or
```bash
npm install ani-cursor
```
@ -35,4 +41,4 @@ document.body.appendChild(h1);
applyCursor("#pizza", "https://archive.org/cors/tucows_169906_Pizza_cursor/pizza.ani");
```
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).

View file

@ -0,0 +1,6 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
};

View file

@ -2,24 +2,14 @@
"name": "ani-cursor",
"version": "0.0.5",
"description": "Render .ani cursors as CSS animations in the browser",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist/",
"src/**/*.ts"
],
"author": "Jordan Eldredge <jordan@jordaneldredge.com>",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/captbaritone/webamp.git",
@ -31,8 +21,6 @@
"homepage": "https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext ts,js",
"test": "jest",
"prepublish": "tsc"
},
@ -40,9 +28,7 @@
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.20.0",
"@swc/jest": "^0.2.24",
"@types/node": "^24.0.10",
"typescript": "^5.6.2"
"typescript": "^5.3.3"
},
"dependencies": {
"byte-data": "18.1.1",
@ -52,13 +38,6 @@
"modulePathIgnorePatterns": [
"dist"
],
"testEnvironment": "jsdom",
"extensionsToTreatAsEsm": [".ts"],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.(t|j)sx?$": ["@swc/jest"]
}
"testEnvironment": "jsdom"
}
}

View file

@ -1,6 +1,6 @@
import fs from "fs";
import path from "path";
import { convertAniBinaryToCSS } from "../index.js";
import { convertAniBinaryToCSS } from "../";
const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;

View file

@ -1,4 +1,4 @@
import { parseAni } from "./parser.js";
import { parseAni } from "./parser";
type AniCursorImage = {
frames: {

View file

@ -1,10 +1,8 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"target": "ES2018" /* Specify ECMAScript target version */,
"lib": ["ES2018", "dom"] /* Specify library files to be included in the compilation. */,
"types": ["jest", "node"] /* Type declaration files to be included in compilation. */,
"module": "ES2020" /* Specify module code generation */,
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
"outDir": "./dist" /* Redirect output structure to the directory. */,
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
@ -26,9 +24,7 @@
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */
"moduleResolution": "node" /* Use Node.js-style module resolution */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export */,
"declaration": true,
"skipLibCheck": true /* Skip type checking of declaration files. */,

View file

@ -0,0 +1,9 @@
# Archive.org Webamp Integration Test
This package contains an automated test to ensure that the [Webamp](https://webamp.org) integration on https://archive.org is still working correectly. It's run twice a day via a GitHub Action, which can be found [here](../../.github/workflows/ia-integration-tests.yml).
## Run
```sh
yarn
node index.js
```

View file

@ -0,0 +1,121 @@
const puppeteer = require("puppeteer");
// Handle a few different cases since specialized pages use specialized classes.
const webampButtonSelector =
".js-webamp-use_skin_for_audio_items, .webamp-link";
// Create our own log function so we can mute it if we want
function log(message) {
console.log(message);
}
const TIMEOUT = 10000;
async function expectSelector(page, selector) {
log(`Waiting for selector ${selector}...`);
await page.waitForSelector(selector, { visible: true, timeout: TIMEOUT });
log(`Found selector ✅`);
}
async function testPage({ url, name, firstTrackText }) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
log(`🏁 [${name}] ${url}`);
await page.goto(url);
await expectSelector(page, webampButtonSelector);
log("Going to click the Webamp button");
// For some reason `page.click` often fails with `Error: Node is either not
// visible or not an HTMLElement` so we use `page.evaluate` instead.
// https://stackoverflow.com/a/52336777
// await page.click(webampButtonSelector, { timeout: TIMEOUT });
await page.evaluate(() => {
document
.querySelector(".js-webamp-use_skin_for_audio_items, .webamp-link")
.click();
});
await expectSelector(page, "#webamp #main-window");
log("Looking for first track...");
const firstTrack = await page.$(".track-cell.current");
if (firstTrack == null) {
throw new Error("Could not find first track");
}
log("Getting text of first track...");
const actualFirstTrackText = await page.evaluate(
(_) => _.textContent,
firstTrack
);
if (!actualFirstTrackText.includes(firstTrackText)) {
throw new Error(
`Could not find track title '${firstTrackText}' in "${actualFirstTrackText}"`
);
}
log("Clicking play...");
await page.click("#webamp #main-window #play", { timeout: TIMEOUT });
await expectSelector(page, "#webamp #main-window.play");
log("✅ Success! Test passed.");
} catch (e) {
log(`🛑 Errored in [${name}]. Wrote screenshot to ./error.png`);
await page.screenshot({ path: "error.png", fullPage: true });
throw e;
} finally {
log("DONE");
await browser.close();
}
}
async function testPageAndRetry(options) {
let retries = 5;
while (retries--) {
try {
await testPage(options);
return;
} catch (e) {
console.error(e);
if (retries > 0) {
console.warn(`Retrying... ${retries} retries left.`);
}
}
}
throw new Error("Failed to pass even after 5 retries");
}
async function main() {
await testPageAndRetry({
name: "Popular",
url: "https://archive.org/details/gd73-06-10.sbd.hollister.174.sbeok.shnf",
firstTrackText: "Grateful Dead - Morning Dew",
});
await testPageAndRetry({
name: "Regular",
url: "https://archive.org/details/78_mambo-no.-5_perez-prado-and-his-orchestra-d.-perez-prado_gbia0009774b",
firstTrackText: "Mambo No. 5",
});
await testPageAndRetry({
name: "Samples Only",
url: "https://archive.org/details/lp_smokey-and-the-bandit-2-original-soundtrac_various-brenda-lee-burt-reynolds-don-willi",
firstTrackText: "Texas Bound And Flyin",
});
await testPageAndRetry({
name: "Stream Only",
url: "https://archive.org/details/cd_a-sweeter-music_sarah-cahill",
firstTrackText: "Be Kind to One Another",
});
await testPageAndRetry({
name: "Another",
url: "https://archive.org/details/78_house-of-the-rising-sun_josh-white-and-his-guitar_gbia0001628b",
firstTrackText: "House Of The Rising Sun",
});
}
(async function () {
try {
await main();
} catch (e) {
console.error(e);
// Ensure process returns an error exit code so that other tools know the test failed.
process.exit(1);
}
})();

View file

@ -0,0 +1,11 @@
{
"name": "archive-org-integration-tests",
"version": "1.0.0",
"description": "Automated tests to ensure the Internet Archive's Webamp integration is working",
"main": "index.js",
"license": "MIT",
"prettier": {},
"dependencies": {
"puppeteer": "^3.0.4"
}
}

View file

@ -1,28 +1,24 @@
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/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: "^_" }],
},
ignorePatterns: ["dist/**"],
};

View file

@ -7,5 +7,4 @@ dist/
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
.env
.next
.env

View file

@ -23,7 +23,7 @@ class DiscordWinstonTransport extends Transport {
let dataString = null;
try {
dataString = JSON.stringify(rest, null, 2);
} catch (_e) {
} catch (e) {
dataString = "COULD NOT STRINGIFY DATA";
}
await this._channel.send(`${message}

View file

@ -17,4 +17,3 @@ The discord bot allows us to:
## Server
This package also includes a GraphQL interface for exploring skins. It is not currently used by anything, but can be useful for inspecting the data.
sudo systemctl reload apache2

View file

@ -1,3 +1,3 @@
export const client = {
export const searchIndex = {
partialUpdateObjects: jest.fn(),
};

View file

@ -59,7 +59,7 @@ async function addModernSkinFromBuffer(
): Promise<Result> {
console.log("Write temporarty file.");
const tempFile = temp.path({ suffix: ".wal" });
fs.writeFileSync(tempFile, new Uint8Array(buffer));
fs.writeFileSync(tempFile, buffer);
console.log("Put skin to S3.");
await S3.putSkin(md5, buffer, "wal");
@ -83,7 +83,7 @@ async function addClassicSkinFromBuffer(
uploader: string
): Promise<Result> {
const tempFile = temp.path({ suffix: ".wsz" });
fs.writeFileSync(tempFile, new Uint8Array(buffer));
fs.writeFileSync(tempFile, buffer);
const tempScreenshotPath = temp.path({ suffix: ".png" });
const logLines: string[] = [];
@ -117,7 +117,6 @@ async function addClassicSkinFromBuffer(
await setHashesForSkin(skin);
// Disable while we figure out our quota
await Skins.updateSearchIndex(ctx, md5);
return { md5, status: "ADDED", skinType: "CLASSIC" };

View file

@ -1,4 +1,5 @@
import { algoliasearch } from "algoliasearch";
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY } from "./config";
import algoliasearch from "algoliasearch";
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY, ALGOLIA_INDEX } from "./config";
export const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);
const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);
export const searchIndex = client.initIndex(ALGOLIA_INDEX);

View file

@ -1,4 +1,4 @@
import { ApiAction } from "./types";
import { ApiAction } from "./app";
import Discord, { TextChannel } from "discord.js";
import * as Config from "../config";
import SkinModel from "../data/SkinModel";
@ -105,7 +105,7 @@ export default class DiscordEventHandler {
case "SYNCED_TO_ARCHIVE": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()} Skipped: ${action.skips.toLocaleString()}.`;
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()}.`;
await dest.send(message);
break;
@ -172,8 +172,7 @@ export default class DiscordEventHandler {
dest,
});
} else {
// Too much nosie
// await DiscordUtils.sendAlreadyReviewed({ md5, dest });
await DiscordUtils.sendAlreadyReviewed({ md5, dest });
}
}
}

View file

@ -1,31 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Query.fetch_skin_by_md5 (debug data) 1`] = `
{
"fetch_skin_by_md5": {
"archive_files": [
{
Object {
"fetch_skin_by_md5": Object {
"archive_files": Array [
Object {
"date": "2000-05-08T07:44:52.000Z",
"file_md5": "a_fake_file_md5",
"filename": "a_fake_archive_file.bmp",
"filename": null,
"is_directory": false,
"size": null,
"skin": {
"skin": Object {
"md5": "a_fake_md5",
},
"text_content": null,
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5.wsz/a_fake_archive_file.bmp",
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5/null",
},
],
"average_color": null,
"download_url": "https://r2.webampskins.org/skins/a_fake_md5.wsz",
"filename": "path.wsz",
"id": "Q2xhc3NpY1NraW5fX2FfZmFrZV9tZDU=",
"internet_archive_item": {
"internet_archive_item": Object {
"identifier": "a_fake_ia_identifier",
"metadata_url": "https://archive.org/metadata/a_fake_ia_identifier",
"raw_metadata_json": null,
"skin": {
"skin": Object {
"md5": "a_fake_md5",
},
"url": "https://archive.org/details/a_fake_ia_identifier",
@ -34,10 +34,10 @@ exports[`Query.fetch_skin_by_md5 (debug data) 1`] = `
"museum_url": "https://skins.webamp.org/skin/a_fake_md5",
"nsfw": false,
"readme_text": null,
"reviews": [],
"reviews": Array [],
"screenshot_url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
"tweeted": false,
"tweets": [],
"tweets": Array [],
"webamp_url": "https://webamp.org?skinUrl=https://r2.webampskins.org/skins/a_fake_md5.wsz",
},
}

View file

@ -1,17 +1,19 @@
import { Application } from "express";
import { knex } from "../../db";
import request from "supertest"; // supertest is a framework that allows to easily test web apis
import { createApp } from "../app";
import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import * as Auth from "../auth";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
import { client } from "../../algolia";
import { createYogaInstance } from "../../app/graphql/yoga";
import { YogaServerInstance } from "graphql-yoga";
import { searchIndex } from "../../algolia";
jest.mock("../../s3");
jest.mock("../../algolia");
jest.mock("../processUserUploads");
jest.mock("../auth");
let yoga: YogaServerInstance<any, any>;
let app: Application;
const handler = jest.fn();
const log = jest.fn();
const logError = jest.fn();
@ -21,30 +23,26 @@ let username: string | undefined;
beforeEach(async () => {
jest.clearAllMocks();
username = "<MOCKED>";
yoga = createYogaInstance({
app = createApp({
eventHandler: handler,
getUserContext: () => new UserContext(username),
extraMiddleware: (req, res, next) => {
req.session.username = username;
next();
},
logger: { log, logError },
});
await knex.migrate.latest();
await knex.seed.run();
});
afterAll(async () => {
await knex.destroy();
});
function gql(templateString: TemplateStringsArray): string {
return templateString[0];
}
async function graphQLRequest(query: string, variables?: any) {
const response = await yoga.fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const body = await response.json();
const { body } = await request(app)
.post("/graphql")
.send({ query, variables: variables ?? {} });
if (body.errors && body.errors.length) {
for (const err of body.errors) {
console.warn(err.message);
@ -108,6 +106,37 @@ describe(".me", () => {
});
});
// TODO: The redirect_uri is different on github
test("/auth", async () => {
const { body } = await request(app).get("/auth").expect(302);
// TODO: The redirect_uri is different on github
// .expect(
// "Location",
// "https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
// );
expect(body).toEqual({});
});
describe("/auth/discord", () => {
test("valid code", async () => {
const response = await request(app)
.get("/auth/discord")
.query({ code: "<A_FAKE_CODE>" })
.expect(302);
// TODO: The location is different on github
// .expect("Location", "https://skins.webamp.org/review/");
// TODO: Assert that we get cookie headers. I think that will not work now
// because express does not think it's secure in a test env.
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
expect(response.body).toEqual({});
});
test("missing code", async () => {
const { body } = await request(app).get("/auth/discord").expect(400);
expect(Auth.auth).not.toHaveBeenCalled();
expect(body).toEqual({ message: "Expected to get a code" });
});
});
describe("Query.skins", () => {
test("no query params", async () => {
const { data } = await graphQLRequest(
@ -127,35 +156,35 @@ describe("Query.skins", () => {
`
);
expect(data.skins).toMatchInlineSnapshot(`
{
Object {
"count": 6,
"nodes": [
{
"nodes": Array [
Object {
"filename": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
},
{
Object {
"filename": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
{
Object {
"filename": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
{
Object {
"filename": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
},
{
Object {
"filename": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
},
{
Object {
"filename": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
@ -183,15 +212,15 @@ describe("Query.skins", () => {
{ first: 2, offset: 1 }
);
expect(data.skins).toMatchInlineSnapshot(`
{
Object {
"count": 6,
"nodes": [
{
"nodes": Array [
Object {
"filename": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
{
Object {
"filename": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
@ -331,10 +360,9 @@ test("Mutation.mark_skin_nsfw", async () => {
type: "MARKED_SKIN_NSFW",
md5: "a_fake_md5",
});
expect(client.partialUpdateObjects).toHaveBeenCalledWith({
indexName: "test-index",
objects: [{ nsfw: true, objectID: "a_fake_md5" }],
});
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
{ nsfw: true, objectID: "a_fake_md5" },
]);
expect(data).toEqual({ mark_skin_nsfw: true });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");

View file

@ -0,0 +1,381 @@
import { Application } from "express";
import { knex } from "../../db";
import request from "supertest"; // supertest is a framework that allows to easily test web apis
import { createApp } from "../app";
import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import * as Auth from "../auth";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
import { searchIndex } from "../../algolia";
jest.mock("../../s3");
jest.mock("../../algolia");
jest.mock("../processUserUploads");
jest.mock("../auth");
let app: Application;
const handler = jest.fn();
const log = jest.fn();
const logError = jest.fn();
let username: string | undefined;
beforeEach(async () => {
jest.clearAllMocks();
username = "<MOCKED>";
app = createApp({
eventHandler: handler,
extraMiddleware: (req, res, next) => {
req.session.username = username;
next();
},
logger: { log, logError },
});
await knex.migrate.latest();
await knex.seed.run();
});
describe("/authed", () => {
test("logged in ", async () => {
const { body } = await request(app).get("/authed").expect(200);
expect(body).toEqual({ username: "<MOCKED>" });
});
test("not logged in", async () => {
username = undefined;
const { body } = await request(app).get("/authed").expect(200);
expect(body).toEqual({ username: null });
});
});
test.skip("/auth", async () => {
const { body } = await request(app)
.get("/auth")
.expect(302)
.expect(
"Location",
"https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
);
expect(body).toEqual({});
});
describe.skip("/auth/discord", () => {
test("valid code", async () => {
const response = await request(app)
.get("/auth/discord")
.query({ code: "<A_FAKE_CODE>" })
.expect(302)
.expect("Location", "https://skins.webamp.org/review/");
// TODO: Assert that we get cookie headers. I think that will not work now
// because express does not think it's secure in a test env.
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
expect(response.body).toEqual({});
});
test("missing code", async () => {
const { body } = await request(app).get("/auth/discord").expect(400);
expect(Auth.auth).not.toHaveBeenCalled();
expect(body).toEqual({ message: "Expected to get a code" });
});
});
describe("/skins/", () => {
test("no query params", async () => {
const { body } = await request(app).get("/skins/");
expect(body).toMatchInlineSnapshot(`
Object {
"skinCount": 6,
"skins": Array [
Object {
"fileName": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
},
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
Object {
"fileName": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
},
Object {
"fileName": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
},
Object {
"fileName": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
},
],
}
`);
});
test("first and offset", async () => {
const { body } = await request(app)
.get("/skins/")
.query({ first: 2, offset: 1 });
expect(body).toMatchInlineSnapshot(`
Object {
"skinCount": 6,
"skins": Array [
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
],
}
`);
});
});
// This is deprecated and fails in CI due to printing as localize date string.
test.skip("/skins/a_fake_md5/debug", async () => {
const { body } = await request(app)
.get("/skins/a_fake_md5/debug")
.expect(200);
expect(body).toMatchSnapshot();
});
test("/skins/a_fake_md5/report", async () => {
const { body } = await request(app)
.post("/skins/a_fake_md5/report")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REVIEW_REQUESTED",
md5: "a_fake_md5",
});
expect(body).toEqual({}); // TODO: Where does the text response go?
});
test("/skins/a_fake_md5/approve", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/approve")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "APPROVED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({ message: "The skin has been approved." });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("APPROVED");
});
describe("/to_review", () => {
test("logged in ", async () => {
const { body } = await request(app).get("/to_review").expect(200);
expect(body).toEqual({
filename: expect.any(String),
md5: expect.any(String),
});
});
test("not logged in ", async () => {
username = undefined;
const { body } = await request(app).get("/to_review").expect(403);
expect(body).toEqual({ message: "You must be logged in" });
});
});
test("/skins/a_md5_that_does_not_exist/approve (404)", async () => {
const { body } = await request(app)
.post("/skins/a_md5_that_does_not_exist/approve")
.expect(404);
expect(body).toEqual({});
expect(handler).not.toHaveBeenCalled();
});
test("/skins/a_fake_md5/reject", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/reject")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REJECTED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({ message: "The skin has been rejected." }); // TODO: Where does the text response go?
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("REJECTED");
});
test("/skins/a_md5_that_does_not_exist/reject (404)", async () => {
const { body } = await request(app)
.post("/skins/a_md5_that_does_not_exist/reject")
.expect(404);
expect(body).toEqual({});
expect(handler).not.toHaveBeenCalled();
});
test("/skins/a_fake_md5/nsfw", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/nsfw")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "MARKED_SKIN_NSFW",
md5: "a_fake_md5",
});
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
{ nsfw: true, objectID: "a_fake_md5" },
]);
expect(body).toEqual({ message: "The skin has been marked as NSFW." });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("NSFW");
});
// TODO: Actually upload some skins?
test("/skins/status", async () => {
const { body } = await request(app)
.post("/skins/status")
.send({ hashes: ["a_fake_md5", "a_missing_md5"] });
expect(body).toEqual({});
});
test("/approved", async () => {
const { body } = await request(app).get("/approved").expect(200);
expect(body).toEqual(["an_approved_md5", "a_tweeted_md5"]);
});
test("/skins/a_fake_md5", async () => {
let response = await request(app).get("/skins/a_fake_md5");
expect(response.body).toEqual({
fileName: "path.wsz",
md5: "a_fake_md5",
nsfw: false,
});
response = await request(app).get("/skins/a_nsfw_md5");
expect(response.body).toEqual({
fileName: "nsfw.wsz",
md5: "a_nsfw_md5",
nsfw: true,
});
await request(app).get("/skins/does_not_exist_md5").expect(404);
});
test("/skins/get_upload_urls", async () => {
const { body } = await request(app)
.post("/skins/get_upload_urls")
.send({
skins: {
"3b73bcd43c30b85d4cad3083e8ac9695": "a_fake_new_file.wsz",
"48bbdbbeb03d347e59b1eebda4d352d0":
"a_new_name_for_a_file_that_exists.wsz",
},
});
expect(S3.getSkinUploadUrl).toHaveBeenCalledWith(
"3b73bcd43c30b85d4cad3083e8ac9695",
expect.any(Number)
);
expect(body).toEqual({
"3b73bcd43c30b85d4cad3083e8ac9695": {
id: expect.any(Number),
url: "<MOCK_S3_UPLOAD_URL>",
},
});
});
test("An Upload Flow", async () => {
// Request an upload URL
const md5 = "3b73bcd43c30b85d4cad3083e8ac9695";
const filename = "a_fake_new_file.wsz";
const skins = { [md5]: filename };
const getUrlsResponse = await request(app)
.post("/skins/get_upload_urls")
.send({ skins });
const id = getUrlsResponse.body[md5].id;
expect(getUrlsResponse.body).toEqual({
[md5]: { id: expect.any(Number), url: "<MOCK_S3_UPLOAD_URL>" },
});
const requestedUpload = await knex("skin_uploads").where({ id }).first();
expect(requestedUpload).toEqual({
filename,
id,
skin_md5: md5,
status: "URL_REQUESTED",
});
// Report that we've uploaded the skin to S3 (we lie)
const uploadedResponse = await request(app)
.post(`/skins/${md5}/uploaded`)
.query({ id })
.send({ skins });
expect(uploadedResponse.body).toEqual({ done: true });
expect(processUserUploads).toHaveBeenCalled();
const reportedUpload = await knex("skin_uploads").where({ id }).first();
expect(reportedUpload).toEqual({
filename,
id,
skin_md5: md5,
status: "UPLOAD_REPORTED",
});
});
test("/stylegan.json", async () => {
const response = await request(app).get("/stylegan.json");
expect(response.body).toMatchInlineSnapshot(`
Array [
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/48bbdbbeb03d347e59b1eebda4d352d0.png",
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
},
Object {
"fileName": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
"url": "https://r2.webampskins.org/screenshots/a_nsfw_md5.png",
},
Object {
"fileName": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_rejected_md5.png",
},
Object {
"fileName": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_tweeted_md5.png",
},
Object {
"fileName": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/an_approved_md5.png",
},
]
`);
});

View file

@ -0,0 +1,208 @@
import router from "./router";
import graphql from "./graphql";
import fileUpload from "express-fileupload";
import cors, { CorsOptions } from "cors";
import bodyParser from "body-parser";
import Sentry from "@sentry/node";
import expressSitemapXml from "express-sitemap-xml";
import * as Skins from "../data/skins";
import express, { Handler, RequestHandler, ErrorRequestHandler } from "express";
import UserContext from "../data/UserContext";
import cookieSession from "cookie-session";
import { SECRET } from "../config";
export type ApiAction =
| { type: "REVIEW_REQUESTED"; md5: string }
| { type: "REJECTED_SKIN"; md5: string }
| { type: "APPROVED_SKIN"; md5: string }
| { type: "MARKED_SKIN_NSFW"; md5: string }
| { type: "SKIN_UPLOADED"; md5: string }
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
| {
type: "GOT_FEEDBACK";
message: string;
email?: string | null;
url?: string | null;
}
| { type: "SYNCED_TO_ARCHIVE"; successes: number; errors: number }
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
| {
type: "POPULAR_TWEET";
bracket: number;
url: string;
likes: number;
date: Date;
}
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
export type EventHandler = (event: ApiAction) => void;
export type Logger = {
log(message: string, context: any): void;
logError(message: string, context: any): void;
};
// Add UserContext to req objects globally
declare global {
namespace Express {
interface Request {
ctx: UserContext;
notify(action: ApiAction): void;
log(message: string): void;
logError(message: string): void;
startTime: number;
session: {
username: string | undefined;
};
}
}
}
type Options = {
eventHandler?: EventHandler;
extraMiddleware?: Handler;
logger?: Logger;
};
export function createApp({ eventHandler, extraMiddleware, logger }: Options) {
const app = express();
if (Sentry) {
app.use(Sentry.Handlers.requestHandler() as RequestHandler);
}
app.use(function (req, res, next) {
req.startTime = Date.now();
next();
});
// https://expressjs.com/en/guide/behind-proxies.html
// This is needed in order to allow `cookieSession({secure: true})` cookies to be sent.
app.set("trust proxy", "loopback");
function use(handler: RequestHandler) {
app.use(handler);
}
const cookieHandler: RequestHandler = cookieSession({
secure: true,
sameSite: "none",
httpOnly: false,
name: "session",
secret: SECRET,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
// @ts-ignore Tests fail if this is missing, but prod is fine.
keys: "what",
});
app.use(cookieHandler);
if (extraMiddleware != null) {
app.use(extraMiddleware);
}
// Add UserContext to request
app.use((req, res, next) => {
req.ctx = new UserContext(req.session.username);
next();
// TODO: Dispose of context?
});
// Attach event handler
app.use((req, res, next) => {
req.notify = (action) => {
if (eventHandler) {
eventHandler(action);
}
};
next();
});
// Attach logger
app.use((req, res, next) => {
const context = {
url: req.url,
params: req.params,
query: req.query,
username: req.ctx.username,
};
req.log = (message) => {
if (logger != null) {
logger.log(message, context);
}
};
req.logError = (message) => {
if (logger != null) {
logger.logError(message, context);
}
};
next();
});
// Configure CORs
app.use(cors(corsOptions));
app.options("*", cors(corsOptions));
// Configure json output
app.set("json spaces", 2);
// parse application/json
app.use(bodyParser.json() as RequestHandler);
// Configure File Uploads
const limits = { fileSize: 50 * 1024 * 1024 };
app.use(fileUpload({ limits }));
// Configure sitemap
app.use(expressSitemapXml(getSitemapUrls, "https://skins.webamp.org"));
// Add routes
app.use("/", router);
app.use("/graphql", graphql);
// The error handler must be before any other error middleware and after all controllers
if (Sentry) {
app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler);
}
// Optional fallthrough error handler
app.use(function onError(err, _req, res, _next) {
console.error(err);
res.statusCode = 500;
res.json({ errorId: res.sentry, message: err.message });
});
return app;
}
async function getSitemapUrls() {
const md5s = await Skins.getAllClassicSkins();
const skinUrls = md5s.map(({ md5, fileName }) => `skin/${md5}/${fileName}`);
return ["/about", "/", "/upload", ...skinUrls];
}
const allowList = [
/https:\/\/skins\.webamp\.org/,
/https:\/\/api\.webamp\.org/,
/https:\/\/webamp\.org/,
/https:\/\/[^.]*\.csb\.app/,
/https:\/\/winamp-skin-museum\.pages\.dev/,
/http:\/\/localhost:3000/,
/http:\/\/localhost:3001/,
/netlify.app/,
/https:\/\/dustinbrett.com/,
];
const corsOptions: CorsOptions = {
credentials: true,
origin: function (origin, callback) {
if (!origin || allowList.some((regex) => regex.test(origin))) {
callback(null, true);
} else {
callback(
new Error(`Request from origin "${origin}" not allowed by CORS.`)
);
}
},
};

View file

@ -2,7 +2,8 @@ import { Int } from "grats";
import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
import UserContext from "../../data/UserContext.js";
import { Ctx } from ".";
import { Query } from "./resolvers/QueryResolver";
/**
* A collection of "modern" Winamp skins
@ -30,7 +31,7 @@ export default class ModernSkinsConnection {
/**
* The list of skins
* @gqlField */
async nodes(ctx: UserContext): Promise<Array<ModernSkinResolver | null>> {
async nodes({ ctx }: Ctx): Promise<Array<ModernSkinResolver | null>> {
const skins = await this._getQuery()
.select()
.limit(this._first)
@ -43,10 +44,16 @@ export default class ModernSkinsConnection {
/**
* All modern skins in the database
* @gqlQueryField */
* @gqlField */
export async function modern_skins(
first: Int = 10,
offset: Int = 0
_: Query,
{
first = 10,
offset = 0,
}: {
first?: Int;
offset?: Int;
}
): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");

View file

@ -5,7 +5,8 @@ import SkinResolver from "./resolvers/SkinResolver";
import LRU from "lru-cache";
import { Int } from "grats";
import { ISkin } from "./resolvers/CommonSkinResolver";
import UserContext from "../../data/UserContext.js";
import { Ctx } from ".";
import { Query } from "./resolvers/QueryResolver";
const options = {
max: 100,
@ -102,7 +103,7 @@ export default class SkinsConnection {
* The list of skins
* @gqlField
*/
async nodes(ctx: UserContext): Promise<Array<ISkin | null>> {
async nodes({ ctx }: Ctx): Promise<Array<ISkin | null>> {
if (this._sort === "MUSEUM") {
if (this._filter) {
throw new Error(
@ -170,18 +171,21 @@ Only the skins that have been tweeted
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlQueryField */
export function skins({
first = 10,
offset = 0,
sort,
filter,
}: {
first?: Int;
offset?: Int;
sort?: SkinsSortOption | null;
filter?: SkinsFilterOption | null;
}): SkinsConnection {
* @gqlField */
export function skins(
_: Query,
{
first = 10,
offset = 0,
sort,
filter,
}: {
first?: Int;
offset?: Int;
sort?: SkinsSortOption | null;
filter?: SkinsFilterOption | null;
}
): SkinsConnection {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}

View file

@ -1,6 +1,7 @@
import { Int } from "grats";
import TweetModel from "../../data/TweetModel";
import { knex } from "../../db";
import { Query } from "./resolvers/QueryResolver";
/** @gqlEnum */
export type TweetsSortOption = "LIKES" | "RETWEETS";
@ -50,17 +51,20 @@ export default class TweetsConnection {
/**
* Tweets tweeted by @winampskins
* @gqlQueryField
* @gqlField
*/
export async function tweets({
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption | null;
}): Promise<TweetsConnection> {
export async function tweets(
_: Query,
{
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption | null;
}
): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}

View file

@ -1,9 +1,42 @@
import UserContext from "../../data/UserContext.js";
import { Router } from "express";
import { createHandler } from "graphql-http/lib/use/express";
// import DEFAULT_QUERY from "./defaultQuery";
import { getSchema } from "./schema";
/** @gqlContext */
export type Ctx = Express.Request;
/** @gqlContext */
export function getUserContext(ctx: Ctx): UserContext {
return ctx.ctx;
}
const router = Router();
router.use(
"/",
createHandler<Ctx>({
schema: getSchema(),
context: (req) => {
return req.raw;
},
/*
graphiql: {
defaultQuery: DEFAULT_QUERY,
},*/
// graphqlHTTP({
// schema: getSchema(),
// graphiql: {
// defaultQuery: DEFAULT_QUERY,
// },
// customFormatErrorFn: (error) => {
// console.error(error);
// return {
// message: error.message,
// locations: error.locations,
// stack: error.stack ? error.stack.split("\n") : [],
// path: error.path,
// };
// },
// extensions,
// }) as RequestHandler
})
);
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,5 +1,6 @@
import { Int } from "grats";
import * as Skins from "../../../data/skins";
import { Query } from "./QueryResolver";
/**
* Statistics about the contents of the Museum's database.
@ -91,7 +92,7 @@ export default class DatabaseStatisticsResolver {
/**
* A namespace for statistics about the database
* @gqlQueryField */
export function statistics(): DatabaseStatisticsResolver {
* @gqlField */
export function statistics(_: Query): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}

View file

@ -1,13 +1,17 @@
import { Ctx } from "..";
import { Mutation } from "./MutationResolver";
/**
* Send a message to the admin of the site. Currently this appears in Discord.
* @gqlMutationField */
* @gqlField */
export async function send_feedback(
req: Ctx,
message: string,
email?: string | null,
url?: string | null
_: Mutation,
{
message,
email,
url,
}: { message: string; email?: string | null; url?: string | null },
req: Ctx
): Promise<boolean> {
req.notify({
type: "GOT_FEEDBACK",

View file

@ -0,0 +1,2 @@
/** @gqlType Mutation */
export type Mutation = unknown;

View file

@ -1,7 +1,8 @@
import { ID } from "grats";
import { Query } from "./QueryResolver";
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import SkinResolver from "./SkinResolver";
import UserContext from "../../../data/UserContext.js";
/**
* A globally unique object. The `id` here is intended only for use within
@ -33,11 +34,12 @@ export function fromId(base64Id: string): { graphqlType: string; id: string } {
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlQueryField
* @gqlField
*/
export async function node(
id: ID,
ctx: UserContext
_: Query,
{ id }: { id: ID },
{ ctx }: Ctx
): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver

View file

@ -0,0 +1,2 @@
/** @gqlType Query */
export type Query = unknown;

View file

@ -1,6 +1,7 @@
import SkinModel from "../../../data/SkinModel";
import * as Skins from "../../../data/skins";
import { Ctx } from "..";
import { Mutation } from "./MutationResolver";
function requireAuthed(handler) {
return (args, req: Ctx) => {
@ -16,8 +17,12 @@ function requireAuthed(handler) {
* Reject skin for tweeting
*
* **Note:** Requires being logged in
* @gqlMutationField */
export function reject_skin(md5: string, req: Ctx): Promise<boolean> {
* @gqlField */
export function reject_skin(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
return _reject_skin(md5, req);
}
@ -36,8 +41,12 @@ const _reject_skin = requireAuthed(async (md5: string, req: Ctx) => {
* Approve skin for tweeting
*
* **Note:** Requires being logged in
* @gqlMutationField */
export function approve_skin(md5: string, req: Ctx): Promise<boolean> {
* @gqlField */
export function approve_skin(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
return _approve_skin(md5, req);
}
@ -56,8 +65,12 @@ const _approve_skin = requireAuthed(async (md5: string, req: Ctx) => {
* Mark a skin as NSFW
*
* **Note:** Requires being logged in
* @gqlMutationField */
export function mark_skin_nsfw(md5: string, req: Ctx): Promise<boolean> {
* @gqlField */
export function mark_skin_nsfw(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
return _mark_skin_nsfw(md5, req);
}
@ -76,9 +89,10 @@ const _mark_skin_nsfw = requireAuthed(async (md5: string, req: Ctx) => {
* Request that an admin check if this skin is NSFW.
* Unlike other review mutation endpoints, this one does not require being logged
* in.
* @gqlMutationField */
* @gqlField */
export async function request_nsfw_review_for_skin(
md5: string,
_: Mutation,
{ md5 }: { md5: string },
req: Ctx
): Promise<boolean> {
req.log(`Reporting skin with hash "${md5}"`);

View file

@ -1,4 +1,4 @@
import UserContext from "../../../data/UserContext.js";
import { Ctx } from "..";
import { Rating, ReviewRow } from "../../../types";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
@ -17,7 +17,7 @@ export default class ReviewResolver {
* The skin that was reviewed
* @gqlField
*/
skin(ctx: UserContext): Promise<ISkin | null> {
skin({ ctx }: Ctx): Promise<ISkin | null> {
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
}

View file

@ -1,11 +1,17 @@
import { Int } from "grats";
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import ClassicSkinResolver from "./ClassicSkinResolver";
import { ISkin } from "./CommonSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import { Query } from "./QueryResolver";
import algoliasearch from "algoliasearch";
import * as Skins from "../../../data/skins";
import { knex } from "../../../db";
// These keys are already in the web client, so they are not secret at all.
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
const index = client.initIndex("Skins");
export default class SkinResolver {
constructor() {
@ -29,11 +35,12 @@ export default class SkinResolver {
/**
* Get a skin by its MD5 hash
* @gqlQueryField
* @gqlField
*/
export async function fetch_skin_by_md5(
md5: string,
ctx: UserContext
_: Query,
{ md5 }: { md5: string },
{ ctx }: Ctx
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
@ -46,79 +53,41 @@ export async function fetch_skin_by_md5(
* Search the database using the Algolia search index used by the Museum.
*
* Useful for locating a particular skin.
* @gqlQueryField
* @gqlField
*/
export async function search_skins(
query: string,
first: Int = 10,
offset: Int = 0,
ctx: UserContext
_: Query,
{
query,
first = 10,
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }: Ctx
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
const skins = await knex("skin_search")
.select("skin_md5")
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
.where("skins.skin_type", "in", [1, 2])
.limit(first)
.offset(offset)
.whereRaw("skin_search MATCH ?", query);
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
return Promise.all(
skins.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* Search the database using SQLite's FTS (full text search) index.
*
* Useful for locating a particular skin.
* @gqlQueryField
*/
export async function search_classic_skins(
query: string,
first: Int = 10,
offset: Int = 0,
ctx: UserContext
): Promise<Array<ClassicSkinResolver | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
// const skins = await knex("skin_search")
// .select("skin_search.skin_md5")
// .leftJoin("skins", "skin_search.skin_md5", "skins.md5")
// .leftJoin("skin_reviews", "skins.md5", "skin_reviews.skin_md5")
// .where("skins.skin_type", "=", 1)
// .orderByRaw("CASE WHEN skin_reviews.review = 'NSFW' THEN 1 ELSE 0 END")
// .limit(first)
// .offset(offset)
// .whereRaw("skin_search MATCH ?", query);
const skins = await knex("skin_search")
.select("skin_md5")
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
.where("skins.skin_type", "=", 1)
.limit(first)
.offset(offset)
.whereRaw("skin_search MATCH ?", query);
return Promise.all(
skins.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
return new ClassicSkinResolver(model);
})
);
}
/**
* A random skin that needs to be reviewed
* @gqlQueryField */
export async function skin_to_review(ctx: UserContext): Promise<ISkin | null> {
* @gqlField */
export async function skin_to_review(
_: Query,
{ ctx }: Ctx
): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}

View file

@ -1,27 +1,31 @@
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import { knex } from "../../../db";
import { ISkin } from "./CommonSkinResolver";
import { Query } from "./QueryResolver";
import SkinResolver from "./SkinResolver";
/**
* Get the status of a batch of uploads by md5s
* @gqlQueryField
* @gqlField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
export async function upload_statuses_by_md5(
md5s: string[],
ctx: UserContext
_: Query,
{ md5s }: { md5s: string[] },
{ ctx }: Ctx
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/**
* Get the status of a batch of uploads by ids
* @gqlQueryField */
* @gqlField */
export async function upload_statuses(
ids: string[],
ctx: UserContext
_: Query,
{ ids }: { ids: string[] },
{ ctx }: Ctx
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "id", keys: ids }, ctx);
}

View file

@ -4,7 +4,7 @@ import * as S3 from "../../../s3";
import * as Skins from "../../../data/skins";
import { processUserUploads } from "../../processUserUploads";
import { Ctx } from "..";
import UserContext from "../../../data/UserContext.js";
import { Mutation } from "./MutationResolver";
// We don't use a resolver here, just return the value directly.
/**
@ -46,8 +46,8 @@ class UploadMutationResolver {
* @gqlField
*/
async get_upload_urls(
files: UploadUrlRequest[],
ctx: UserContext
{ files }: { files: UploadUrlRequest[] },
{ ctx }: Ctx
): Promise<Array<UploadUrl | null>> {
const missing: UploadUrl[] = [];
await Parallel.each(
@ -70,8 +70,7 @@ class UploadMutationResolver {
* @gqlField
*/
async report_skin_uploaded(
id: string,
md5: string,
{ id, md5 }: { id: string; md5: string },
req: Ctx
): Promise<boolean> {
// TODO: Validate md5 and id;
@ -84,7 +83,7 @@ class UploadMutationResolver {
/**
* Mutations for the upload flow
* @gqlMutationField */
export async function upload(): Promise<UploadMutationResolver> {
* @gqlField */
export async function upload(_: Mutation): Promise<UploadMutationResolver> {
return new UploadMutationResolver();
}

View file

@ -1,17 +1,18 @@
import UserContext from "../../../data/UserContext.js";
import { Ctx } from "..";
import { Query } from "./QueryResolver";
/** @gqlType User */
export default class UserResolver {
/** @gqlField */
username(ctx: UserContext): string | null {
username({ ctx }: Ctx): string | null {
return ctx.username;
}
}
/**
* The currently authenticated user, if any.
* @gqlQueryField
* @gqlField
*/
export function me(): UserResolver | null {
export function me(_: Query): UserResolver | null {
return new UserResolver();
}

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.
@ -166,17 +161,7 @@ type ArchiveFile {
serverless Cloudflare function which tries to exctact the file on the fly.
It may not work for all files.
"""
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
url: String @semanticNonNull
}
"""A classic Winamp skin"""
@ -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
@ -436,12 +411,6 @@ type Query {
"""
node(id: ID!): Node
"""
Search the database using SQLite's FTS (full text search) index.
Useful for locating a particular skin.
"""
search_classic_skins(first: Int! = 10, offset: Int! = 0, query: String!): [ClassicSkin] @semanticNonNull
"""
Search the database using the Algolia search index used by the Museum.
Useful for locating a particular skin.

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import * as Skins from "../data/skins";
import S3 from "../s3";
import { addSkinFromBuffer } from "../addSkin";
import { EventHandler } from "./types";
import { EventHandler } from "./app";
import DiscordEventHandler from "./DiscordEventHandler";
async function* reportedUploads(): AsyncGenerator<
@ -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)
),
]);
}
@ -82,6 +82,7 @@ async function processGivenUserUploads(
message: e.message,
} as const;
eventHandler(action);
throw e;
console.error(e);
}
}

View file

@ -0,0 +1,332 @@
import { Router } from "express";
import asyncHandler from "express-async-handler";
import SkinModel from "../data/SkinModel";
import * as Skins from "../data/skins";
import {
DISCORD_CLIENT_ID,
DISCORD_REDIRECT_URL,
LOGIN_REDIRECT_URL,
} from "../config";
import S3 from "../s3";
import LRU from "lru-cache";
import { MuseumPage } from "../data/skins";
import { processUserUploads } from "./processUserUploads";
import { auth } from "./auth";
import * as Parallel from "async-parallel";
const router = Router();
const options = {
max: 100,
maxAge: 1000 * 60 * 60,
};
let skinCount: number | null = null;
const cache = new LRU<string, MuseumPage>(options);
// Purposefully REST
router.get(
"/auth/",
asyncHandler(async (req, res) => {
res.redirect(
302,
`https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(
DISCORD_CLIENT_ID
)}&redirect_uri=${encodeURIComponent(
DISCORD_REDIRECT_URL
)}&response_type=code&scope=identify%20guilds`
);
})
);
// @deprecated Use GraphQL
router.get(
"/authed/",
asyncHandler(async (req, res) => {
res.json({ username: req.ctx.username });
})
);
// Purposefully REST
router.get(
"/auth/discord",
asyncHandler(async (req, res) => {
const code = req.query.code as string | undefined;
if (code == null) {
res.status(400).send({ message: "Expected to get a code" });
return;
}
const username = await auth(code);
if (username == null) {
res.status(400).send({ message: "Invalid code" });
return;
}
req.session.username = username;
// TODO: What about dev?
res.redirect(302, LOGIN_REDIRECT_URL);
})
);
// @deprecated Use GraphQL
router.get(
"/skins/",
asyncHandler(async (req, res) => {
if (skinCount == null) {
skinCount = await Skins.getClassicSkinCount();
}
const { offset = 0, first = 100 } = req.query;
const key = req.originalUrl;
const cached = cache.get(key);
if (cached != null) {
req.log(`Cache hit for ${key}`);
res.json({ skinCount, skins: cached });
return;
}
req.log(`Getting offset: ${offset}, first: ${first}`);
const start = Date.now();
const skins = await Skins.getMuseumPage({
offset: Number(offset),
first: Number(first),
});
req.log(`Query took ${(Date.now() - start) / 1000}`);
req.log(`Cache set for ${key}`);
cache.set(key, skins);
res.json({ skinCount, skins });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/get_upload_urls",
asyncHandler(async (req, res) => {
const payload = req.body.skins as { [md5: string]: string };
const missing = {};
await Parallel.each(
Object.entries(payload),
async ([md5, filename]) => {
if (!(await SkinModel.exists(req.ctx, md5))) {
const id = await Skins.recordUserUploadRequest(md5, filename);
const url = S3.getSkinUploadUrl(md5, id);
missing[md5] = { id, url };
}
},
5
);
res.json(missing);
})
);
// @deprecated Use GraphQL
router.post(
"/feedback",
asyncHandler(async (req, res) => {
const payload = req.body as {
email?: string;
message: string;
url?: string;
};
req.notify({
type: "GOT_FEEDBACK",
url: payload.url,
message: payload.message,
email: payload.email,
});
res.json({ message: "sent" });
})
);
// @deprecate Use GraphQL
router.post(
"/skins/status",
asyncHandler(async (req, res) => {
const statuses = await Skins.getUploadStatuses(req.body.hashes);
res.json(statuses);
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
res.json({
md5: skin.getMd5(),
nsfw: await skin.getIsNsfw(),
fileName: await skin.getFileName(),
});
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5/metadata",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
const [nsfw, fileName, readme] = await Promise.all([
skin.getIsNsfw(),
skin.getFileName(),
skin.getReadme(),
]);
res.json({
md5: skin.getMd5(),
nsfw,
fileName,
readme,
});
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5/debug",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
res.json(await skin.debug());
})
);
function requireAuthed(req, res, next) {
if (!req.ctx.authed()) {
res.status(403);
res.send({ message: "You must be logged in" });
} else {
next();
}
}
// @deprecated Use GraphQL
router.get(
"/to_review",
requireAuthed,
asyncHandler(async (req, res) => {
const { filename, md5 } = await Skins.getSkinToReview();
res.json({ filename, md5 });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/reject",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Rejecting skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.reject(req.ctx, md5);
req.notify({ type: "REJECTED_SKIN", md5 });
res.send({ message: "The skin has been rejected." });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/approve",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Approving skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.approve(req.ctx, md5);
req.notify({ type: "APPROVED_SKIN", md5 });
res.send({ message: "The skin has been approved." });
})
);
// @deprecated Use GraphQL
// Unlike /report, this marks the skin NSFW right away without sending to
// Discord. Because of this, it requires auth.
router.post(
"/skins/:md5/nsfw",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Approving skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.markAsNSFW(req.ctx, md5);
req.notify({ type: "MARKED_SKIN_NSFW", md5 });
res.send({ message: "The skin has been marked as NSFW." });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/report",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Reporting skin with hash "${md5}"`);
// Blow up if there is no skin with this hash
await SkinModel.fromMd5Assert(req.ctx, md5);
req.notify({ type: "REVIEW_REQUESTED", md5 });
res.send("The skin has been reported and will be reviewed shortly.");
})
);
// @deprecated Use GraphQL
// User reports that they uploaded a skin
router.post(
"/skins/:md5/uploaded",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const id = req.query.id as string;
if (id == null) {
throw new Error("Missing upload id");
}
// TODO: Validate md5 and id;
await Skins.recordUserUploadComplete(md5, id);
// Don't await, just kick off the task.
processUserUploads(req.notify);
res.json({ done: true });
})
);
// @deprecated Use GraphQL
router.get(
"/approved",
asyncHandler(async (req, res) => {
const approved = await Skins.getAllApproved();
res.json(approved);
})
);
// @deprecated Special purpose URL
router.get(
"/stylegan.json",
asyncHandler(async (req, res) => {
const images = await Skins.getAllClassicScreenshotUrls();
res.json(images);
})
);
export default router;

View file

@ -0,0 +1,38 @@
// import Sentry from "@sentry/node";
import dotenv from "dotenv";
dotenv.config();
import { createApp } from "./app";
import DiscordEventHandler from "./DiscordEventHandler";
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
const handler = new DiscordEventHandler();
// GO!
const app = createApp({
eventHandler: (action) => handler.handle(action),
logger: {
log: (message, context) => console.log(message, context),
logError: (message, context) => console.error(message, context),
},
});
app.listen(port, () => {
console.log(
`Winamp Skin Museum database API app listening on http://localhost:${port}`
);
console.log(`Explore: http://localhost:${port}/graphql`);
});
// Initialize Sentry after we start listening. Any crash at start time will appear in the console and we'll notice.
/*
Sentry.init({
dsn:
"https://0e6bc841b4f744b2953a1fe5981effe6@o68382.ingest.sentry.io/5508241",
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: 1.0,
});
*/

View file

@ -1,54 +0,0 @@
import UserContext from "../data/UserContext";
export type ApiAction =
| { type: "REVIEW_REQUESTED"; md5: string }
| { type: "REJECTED_SKIN"; md5: string }
| { type: "APPROVED_SKIN"; md5: string }
| { type: "MARKED_SKIN_NSFW"; md5: string }
| { type: "SKIN_UPLOADED"; md5: string }
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
| {
type: "GOT_FEEDBACK";
message: string;
email?: string | null;
url?: string | null;
}
| {
type: "SYNCED_TO_ARCHIVE";
successes: number;
errors: number;
skips: number;
}
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
| {
type: "POPULAR_TWEET";
bracket: number;
url: string;
likes: number;
date: Date;
}
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
export type EventHandler = (event: ApiAction) => void;
export type Logger = {
log(message: string, context: any): void;
logError(message: string, context: any): void;
};
// Add UserContext to req objects globally
declare global {
namespace Express {
interface Request {
ctx: UserContext;
notify(action: ApiAction): void;
log(message: string): void;
logError(message: string): void;
session: {
username: string | undefined;
};
}
}
}

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,11 +0,0 @@
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "../skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { hash, fileName: _fileName } = await params;
return generateSkinPageMetadata(hash);
}
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,11 +0,0 @@
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "./skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { hash } = await params;
return generateSkinPageMetadata(hash);
}
export default function Page() {
return null;
}

View file

@ -1,50 +0,0 @@
import { Metadata } from "next";
import SkinModel from "../../../../data/SkinModel";
import UserContext from "../../../../data/UserContext";
export async function generateSkinPageMetadata(
hash: string
): Promise<Metadata> {
const skin = await SkinModel.fromMd5Assert(new UserContext(), hash);
const fileName = await skin.getFileName();
const readme = await skin.getReadme();
const imageUrl = `https://skin-museum-og-captbaritone-webamp.vercel.app/api/og?md5=${hash}`;
const images = [
{
alt: `Screenshot of the Winamp skin ${fileName}.`,
url: imageUrl,
width: 1200,
height: 600,
},
];
const title = `${fileName} - Winamp Skin Museum`;
const description =
readme == null
? `The Winamp Skin "${fileName}" in the Winamp Skin Museum. Explore skins, view details, and interact with previews.`
: readme.slice(0, 300);
return {
title,
description,
alternates: {
canonical: skin.getMuseumUrl(),
},
openGraph: {
title,
description,
images,
type: "website",
siteName: "Winamp Skin Museum",
},
twitter: {
card: "summary_large_image",
site: "@winampskins",
title,
description,
creator: "@captbaritone",
images,
},
};
}

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

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