Compare commits

...

187 commits

Author SHA1 Message Date
Jordan Eldredge
2fe3235d51 Remove contained example and clarify position requirements 2026-01-01 15:44:11 -08:00
Jordan Eldredge
54dbe369d4
Fix TypeScript error in Webamp renderInto call (#1340)
Add non-null assertion operator to ref.current when calling webamp.renderInto()
to match the pattern used elsewhere in the component and resolve the CI type error:
"Argument of type 'HTMLDivElement | null' is not assignable to parameter of type 'HTMLElement'"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-01 15:19:31 -08:00
Jordan Eldredge
bce845962f Enable Webamp for scroll UI 2025-12-31 15:18:45 -08:00
Jordan Eldredge
4b784b6eaf Start wiring up Webamp in scroller museum UI 2025-12-31 15:00:16 -08:00
Jordan Eldredge
52f12327fa Persist search query in URL 2025-12-31 15:00:16 -08:00
Jordan Eldredge
1d10f7a1f4 Don't wait as long to hint that you can scroll 2025-12-31 15:00:16 -08:00
Jordan Eldredge
99f06d3bfc Remove logging 2025-12-31 15:00:16 -08:00
Lorenzo Stanco
162025f8a0
Webamp optionally fully contained into a DOM element (#1338)
* Webamp optionally fully contained into a DOM element

* Fix spelling

* Reenable updating search index for uploads

* Replace contained flag on renderWhenReady with a new method. Add docs

---------

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>
2025-12-31 14:59:14 -08:00
Jordan Eldredge
f600fb0344 lints 2025-12-30 04:46:47 +00:00
Jordan Eldredge
04c396ed1f Scroll search 2025-12-29 20:22:39 -08:00
Jordan Eldredge
f74d7a6cdf Move search input 2025-12-29 17:38:14 -08:00
Jordan Eldredge
1fb930cd63 New pages for scroll 2025-12-29 17:29:11 -08:00
Jordan Eldredge
8d4ff41f42 Improve scroll UI 2025-12-29 17:03:29 -08:00
Jordan Eldredge
e062a51a88 Improve scroll UI 2025-12-29 15:32:03 -08:00
Jordan Eldredge
0895f9191f Add explicit app routes 2025-12-29 15:17:06 -08:00
Jordan Eldredge
3b4e5b17c3 Remove blocklist of skins with viruses. These are now purged. 2025-12-29 13:15:46 -08:00
Jordan Eldredge
91618c9c6b Block downloading skin that seems to trigger security block 2025-12-29 11:52:11 -08:00
Jordan Eldredge
6c732f8e24 Add bulk download page 2025-12-29 11:35:45 -08:00
Jordan Eldredge
d87cb6ffa3 Switch away from cached api 2025-12-26 19:20:30 +00:00
Jordan Eldredge
6997c852f9 Get uploads working again 2025-12-26 19:20:30 +00:00
Jordan Eldredge
61476591f8 Trusted publishing 2025-12-17 16:51:54 -08:00
Jordan Eldredge
33003a8f8f
Patch butterchurn to use eel-wasm 98 to fix mod bug (#1334) 2025-12-15 22:21:26 -08:00
Jordan Eldredge
50a7c2df49
Fix lints (#1335)
* Fix lints

* Fix typechecking
2025-12-15 22:12:14 -08:00
Jordan Eldredge
1f875a6155 Migrate from pm2 to systemd 2025-12-03 18:20:18 -05:00
Jordan Eldredge
26a6002ce8 Fix deploy 2025-12-03 18:12:43 -05:00
Jordan Eldredge
bd6c978d79 Remove duplicate function 2025-12-03 18:12:43 -05:00
Jordan Eldredge
18ee5418b6 Avoid circular imports 2025-12-03 18:12:43 -05:00
Jordan Eldredge
af21934296 Use node version from nvm 2025-12-03 18:12:43 -05:00
Jordan Eldredge
4b793c30b2 Deploy take 1 2025-12-03 18:12:43 -05:00
Jordan Eldredge
a6b0350a00 Get syncing to the archive working again 2025-12-03 18:12:43 -05:00
Jordan Eldredge
964a7c5f2f
Upgrade next (#1333) 2025-12-03 15:12:22 -08:00
Jordan Eldredge
b00e359a78
Enable more lints (#1330) 2025-11-28 12:19:10 -08:00
Jordan Eldredge
d159308352
Use turbo lint for root lint script (#1329)
This ensures `pnpm run lint` at the monorepo root runs the same
lint tasks as CI (`npx turbo lint`), providing consistent behavior
between local development and CI.
2025-11-28 12:06:41 -08:00
Jordan Eldredge
d687f4b06c
Disable silly lint rule (#1328) 2025-11-28 11:53:25 -08:00
Jordan Eldredge
8fa7701b47
Remove legacy JS files from winamp-eqf package (#1327)
These files were duplicates of the TypeScript source files in src/.
The package builds from src/*.ts to built/*.js, so these root-level
.js files were unused and causing lint failures when running
`pnpm run lint` from the monorepo root.
2025-11-28 11:51:02 -08:00
Jordan Eldredge
1da77a640a
Consolidate ESLint configs into root (#1324)
Move general-purpose lint rules from packages/webamp/.eslintrc to the root
.eslintrc so they apply to all packages consistently. This includes:

- Core JavaScript best practices (no-var, prefer-const, eqeqeq, etc.)
- TypeScript-specific rules (@typescript-eslint/no-unused-vars with patterns)
- Prettier integration

Package-specific configs now only contain rules unique to their needs:
- webamp: React, import, and react-hooks plugin rules
- skin-database: Extends @typescript-eslint/recommended, disables rules that
  conflict with existing code style
- webamp-modern: Unchanged (has root: true for isolation)

Also fixes lint errors in skin-database:
- Consolidate duplicate imports in App.js and Feedback.js
- Add radix parameter to parseInt
- Prefix unused function parameters with underscore
- Convert var to let/const
- Fix type import for Shooter
2025-11-27 21:32:10 -08:00
Jordan Eldredge
642fb964d6
Add skin-museum-og lint to CI (#1321)
The skin-museum-og package has a lint script but it was not included
in turbo.json, so it wasn't being checked in CI. This adds the
skin-museum-og#lint task to turbo.json so it will be linted along
with the other packages.
2025-11-27 18:34:41 -08:00
Jordan Eldredge
8358d4843c
Add ESLint to ani-cursor and include in CI (#1323)
- Add lint script to ani-cursor package.json
- Add ani-cursor#lint task to turbo.json so it runs in CI
2025-11-27 18:31:36 -08:00
Jordan Eldredge
bbd1d1224e
Add ESLint to winamp-eqf and include in CI (#1322)
- Add lint script to winamp-eqf package.json
- Add winamp-eqf#lint task to turbo.json so it runs in CI
2025-11-27 18:31:27 -08:00
Jordan Eldredge
b672de2515
Fix webamp-modern build outputs in turbo.json (#1320)
The webamp-modern#build task had outputs set to an empty array,
which meant turbo wouldn't cache/restore the build directory.
This caused the deploy script to fail when the mv command couldn't
find the build directory on cache hits.

Changed outputs from [] to ["build/**"] to properly cache and
restore the build output.
2025-11-27 17:46:44 -08:00
Jordan Eldredge
014c8eab28
Add missing BLUESKY env vars to test setup (#1319)
The skin-database tests were failing in CI because BLUESKY_PASSWORD and
BLUESKY_USERNAME environment variables were added to config.ts but
not to the jest-setup.js file that provides dummy values for tests.
2025-11-27 16:24:04 -08:00
Eris Lund
a56cbc54c5
High bitrate/samplerate is now correctly clipped (#1318)
* High bitrate/samplerate is now correctly clipped

This makes the ``kbps`` and ``kHz`` displays in the main window correctly emulate what Winamp does with high bitrates/samplerates.

* Moved globals to be local to their designated functions

Division is no longer performed in each if condition
Default to displaying "0" for the kbps and khz fields (doesn't seem to trigger, though)

* Use padStart and slice to more properly format the data

Added comment by Justin Frankel on the meaning of H and C

* Display "0" while gathering bitrate and khz

* Remove logging of kbps in console

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>

* Assign ``finalKhz`` properly

* Make CI hopefully happy

---------

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>
2025-11-27 16:10:11 -08:00
Jordan Eldredge
8efe121f3c More stuff for the new layout 2025-11-07 21:38:21 -08:00
Jordan Eldredge
c778464c42 Avoid url takeover and loading glitch 2025-11-07 19:58:30 -08:00
Jordan Eldredge
d6245c7c7e Add scroll hint 2025-11-07 19:41:50 -08:00
Jordan Eldredge
f1339901e6 Stub out permalink page 2025-11-07 19:12:08 -08:00
Jordan Eldredge
811fc977c4 Clean up 2025-11-07 18:56:10 -08:00
Jordan Eldredge
7afe3bd45b Stub out menu bar 2025-11-07 18:51:49 -08:00
Jordan Eldredge
608242b200 Improve landscape screens for scroll 2025-11-07 17:28:42 -08:00
Jordan Eldredge
f3054192e6 Make page dynamic and fix scroll loading on ios 2025-11-07 16:08:41 -08:00
Jordan Eldredge
0b2ff44b1c Action buttons 2025-11-07 15:44:54 -08:00
Jordan Eldredge
52ff84d29b Make scroll sessions more dynamic 2025-11-07 15:23:02 -08:00
Jordan Eldredge
fbe3a0090f Fix some layout issues 2025-11-04 21:44:50 -08:00
Jordan Eldredge
0705e9d89e Stub out shorts 2025-11-04 21:33:58 -08:00
Jordan Eldredge
340e2249ae Clean up outdated references to yarn 2025-11-03 15:39:26 -08:00
Jordan Eldredge
4b405bc831 Clean up console logs 2025-11-03 08:05:02 -08:00
Jordan Eldredge
50d5dbbf4f Always use .wsz in museum pages 2025-11-03 08:04:54 -08:00
Jordan Eldredge
96ffdcda59 Clean up log 2025-11-03 07:54:59 -08:00
Jordan Eldredge
e5ed88c8ec Bluesky bot 2025-10-10 20:25:57 -07:00
Jordan Eldredge
3c882550e3 Downgrade skin museum OG React 2025-10-10 16:22:51 -07:00
Jordan Eldredge
6434ecc626 Upgrade Grats 2025-10-03 17:48:14 -07:00
Jordan Eldredge
a20bab1877 Spelling 2025-07-21 22:24:23 -07:00
Jordan Eldredge
a0cecb8f93 Make shade mode trigger on click like the rest of the buttons 2025-07-21 20:03:04 -07:00
Jordan Eldredge
0608b2f9c6 Fix marquee dragging 2025-07-21 19:48:51 -07:00
Jordan Eldredge
27ba138a3e Try package-specific Netlify build 2025-07-15 20:13:45 -07:00
Jordan Eldredge
cb651adfaf Run library build one at a time to avoid OOM on Netlify 2025-07-15 20:00:41 -07:00
Jordan Eldredge
224b4b8058 Revive Webamp modern on Netlify 2025-07-15 19:33:26 -07:00
Jordan Eldredge
f35f1242ca Try harder to avoid OOms in Netlify build 2025-07-15 19:25:16 -07:00
Jordan Eldredge
acff24b7bb Try to avoid OOM on Netlify 2025-07-15 19:13:06 -07:00
Jordan Eldredge
e99b2ab6f7 Fix deploy script 2025-07-15 19:02:17 -07:00
Jordan Eldredge
a30ab82ccc Add example showing how to use requireButterchurnPresets 2025-07-15 19:00:09 -07:00
Jordan Eldredge
eaba9667e2 Try to fix netlify deploy 2025-07-15 18:57:35 -07:00
Jordan Eldredge
41cfbbb63c Update readme to reflect package changes 2025-07-15 16:59:41 -07:00
Jordan Eldredge
6f8f85c865
Allow users to provide their own Butterchurn presets (#1309)
* Allow users to provide their own Butterchurn presets

* Ensure entrypoints have anchor links
2025-07-15 16:58:52 -07:00
Jordan Eldredge
e82db4cddd Add example of using webamp/lazy entrypoint with vite 2025-07-15 16:41:24 -07:00
Jordan Eldredge
0d7cb8285f Document entrypoints and recommend webamp/butterchurn as the default. 2025-07-14 21:28:30 -07:00
Jordan Eldredge
a4dec85406 Document butterchurn entrypoint 2025-07-14 17:57:20 -07:00
Jordan Eldredge
5e88d4e37b Ignore dist in built 2025-07-14 17:06:30 -07:00
Jordan Eldredge
9b03dcd077 Version 2.2.0 release 2025-07-14 16:58:20 -07:00
Jordan Eldredge
cb2948c2d3 Add butterchurn security change to changelog 2025-07-14 16:56:42 -07:00
Jordan Eldredge
b7d759618e
Don't allow evaling JS in Milkdrop preset files (#1308)
* Don't allow evaling JS in Milkdrop preset files

* Add link
2025-07-14 16:31:27 -07:00
Jordan Eldredge
910995d469 Add links to changelog 2025-07-13 12:48:55 -07:00
Jordan Eldredge
35e6128224 Unfork butterchurn 2025-07-12 23:46:54 -07:00
Jordan Eldredge
b02fc7a5c6 Update docs 2025-07-12 23:44:21 -07:00
Jordan Eldredge
161c14b598 Bump version in butterchurn example 2025-07-12 23:03:32 -07:00
Jordan Eldredge
7cdc35b93a Update changelog to call out new butterchurn entrypoint 2025-07-12 22:56:03 -07:00
Jordan Eldredge
19668851d6 Bump butterchurn version to mostly unfork 2025-07-12 22:55:51 -07:00
Jordan Eldredge
b76491001c Remove touch 2025-07-12 13:11:11 -07:00
Jordan Eldredge
dbc64a8287 Skip cache to debug ci 2025-07-10 15:17:45 -07:00
Jordan Eldredge
9f789ae2ba
Fix butterchurn import in withButterchurn (#1306) 2025-07-10 14:58:10 -07:00
Jordan Eldredge
95e96ddb02 Update tests to use array not set 2025-07-10 14:57:17 -07:00
Jordan Eldredge
12c31f65ae Use fork of butterchurn 2025-07-10 09:03:55 -07:00
Jordan Eldredge
af1f294ada Format 2025-07-09 22:53:17 -07:00
Jordan Eldredge
a06485f1be Avoid some lint errors 2025-07-09 21:05:54 -07:00
Jordan Eldredge
a4f14d9a86 Resolve lint errors 2025-07-09 20:52:57 -07:00
Jordan Eldredge
4ead993d64 Fix tests for Algolia use 2025-07-09 20:39:18 -07:00
Jordan Eldredge
9d88134988 Finish algolia upgrade 2025-07-09 14:24:42 -07:00
Jordan Eldredge
157f8d28df Fix typescript errors 2025-07-09 14:07:30 -07:00
Jordan Eldredge
c55212b363 Fix download links and upgrade some dependencies 2025-07-09 13:25:06 -07:00
Jordan Eldredge
15b15d081a Revive compile skin script and convert to typescript 2025-07-09 13:25:06 -07:00
Jordan Eldredge
51859506db Fix compile skin script 2025-07-09 13:25:06 -07:00
Jordan Eldredge
2dee12e9e0 Avoid non-serializable object in Redux store 2025-07-09 13:25:06 -07:00
Jordan Eldredge
daf06567b0 Consolidate dependency versions 2025-07-09 13:25:05 -07:00
Jordan Eldredge
3a1ec73825
Update README.md 2025-07-08 07:32:24 -07:00
Jordan Eldredge
b090082dfa Check Grats artifacts in CI 2025-07-07 15:33:05 -07:00
Jordan Eldredge
6ce866f48c Include Grats in turbo.json 2025-07-07 15:11:18 -07:00
Jordan Eldredge
26e8e6dfeb Run more tests in CI 2025-07-07 12:51:20 -07:00
Jordan Eldredge
8425adaeff
Upgrade node 22 lts (#1305)
* Upgrade Node.js to v22 LTS across the entire repo

- Update CI workflows (.github/workflows/ci.yml, code-size.yml) to use Node.js 22.x
- Update Netlify config to use Node.js 22.11.0
- Update all package.json engines to require Node.js 22.0.0+
- Add .nvmrc file with Node.js 22 for local development
- Update documentation to reflect Node.js 22+ requirement
- Update skin-database scripts to use Node.js 22
- Tested locally with nvm and confirmed builds work correctly

Node.js v22 is the current LTS version and provides improved performance
and security features. All packages now require Node.js 22+ to ensure
consistency across development, CI, and production environments.

* Don't update node version here
2025-07-07 10:30:13 -07:00
Jordan Eldredge
c2c067588d Tidy ci 2025-07-06 22:56:29 -07:00
Jordan Eldredge
f45736c0b6
Try Turborepo (#1304)
* Try Turborepo

* Add webamp as explicit dependency of docs

* Update pnpm lock

* Generate types as part of webamp build-library

* To many dashes
2025-07-06 22:50:04 -07:00
Jordan Eldredge
28c36e5141 Ensure builds happen early enough in CI 2025-07-06 18:54:20 -07:00
Jordan Eldredge
99e4ae4f90 Try to fix tests in CI 2025-07-06 18:16:24 -07:00
Jordan Eldredge
ce8c4b1bcc Prettier 2025-07-06 18:13:37 -07:00
Jordan Eldredge
fee8030882 Clean up create-react-app cruft 2025-07-06 18:12:50 -07:00
Jordan Eldredge
46ad06054e Clean up old parcel cruft 2025-07-06 18:07:31 -07:00
Jordan Eldredge
60c94b4f00 Build dependencies before type checking 2025-07-06 18:02:31 -07:00
Jordan Eldredge
e641c61b64 Fixup winamp-eqf tests 2025-07-06 18:00:48 -07:00
Jordan Eldredge
f471cd1e69 Publish all 2025-07-06 17:39:36 -07:00
Jordan Eldredge
aef14f205d Update ani-cursor build 2025-07-06 17:30:06 -07:00
Jordan Eldredge
dbf54596e4 Type winamp-eqf 2025-07-06 17:17:55 -07:00
Jordan Eldredge
f2fd00b65b Improve typechecking of webamp-modern 2025-07-06 16:43:59 -07:00
Jordan Eldredge
b99ea291eb Fix type checking of skin-database 2025-07-06 16:33:09 -07:00
Jordan Eldredge
8abcf9d139 Typecheck more packages in CI 2025-07-06 16:23:57 -07:00
Jordan Eldredge
f9f892bde1 Try to fix npm release 2025-07-06 15:59:58 -07:00
Jordan Eldredge
08ec7ce69f
Migrate from yarn to pnpm (#1303)
* Migrate from yarn to pnpm

This comprehensive migration includes:

### Configuration Updates
- Updated root package.json with pnpm workspace configuration
- Added packageManager field and pnpm overrides for graphql version
- Updated GitHub Actions workflows (.github/workflows/ci.yml, code-size.yml)
- Updated Netlify configuration (netlify.toml)
- Updated deployment script (deploy.sh)

### Documentation Updates
- Updated all README files to use pnpm instead of yarn
- Updated installation and build instructions across packages:
  - packages/webamp/README.md
  - packages/webamp-modern/README.md
  - packages/webamp-docs/README.md
  - packages/ani-cursor/README.md
  - packages/webamp/demo/readme.md

### Lock File Migration
- Removed yarn.lock
- Generated pnpm-lock.yaml preserving exact dependency versions
- Moved resolutions from skin-database package.json to root pnpm overrides
- Created pnpm-workspace.yaml for optimized workspace configuration

### CI/CD Updates
- Updated all yarn commands to use pnpm equivalents
- Changed yarn workspace commands to pnpm --filter syntax
- Updated cache keys to use pnpm-lock.yaml instead of yarn.lock
- Added pnpm/action-setup for GitHub Actions

### Validation
- Tested builds for webamp, webamp-modern, ani-cursor, webamp-docs
- Tested installation and linting for skin-database
- Verified dependency resolution consistency
- Confirmed all scripts work with pnpm

All package versions remain identical to yarn.lock, ensuring no breaking changes.

* Fix GitHub Actions CI: Install pnpm before using cache

The GitHub Actions workflow was trying to cache pnpm before installing it.
Fixed by reordering steps in all jobs to:
1. Install pnpm first
2. Setup Node.js with pnpm cache
3. Install dependencies

This ensures pnpm is available when setting up the cache.

* Fix pnpm overrides configuration format

Move overrides from pnpm.overrides to top-level overrides in package.json
to match the format expected by pnpm lockfile. This resolves the
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH error in CI.

* Update CI to use pnpm version 9 to match lockfile format

* Add missing @types dependencies for ani-cursor

Add @types/jest and @types/node as devDependencies to ani-cursor package.
These were missing but referenced in tsconfig.json, causing TypeScript
compilation failures in CI with pnpm's stricter package isolation.

* Fix dependency isolation issues for pnpm migration

- Add strtok3 as direct dependency to webamp package (was transitive)
- Add missing Babel plugins that were accessible as transitive deps with yarn
- These packages need to be explicit dependencies for pnpm's stricter isolation

Addresses missing dependencies that caused CI build failures:
- Cannot find module 'strtok3'
- Cannot find package '@babel/plugin-proposal-nullish-coalescing-operator'
- Cannot find package '@babel/plugin-proposal-optional-chaining'

* Add @babel/preset-env to webamp devDependencies

- Fixes build-library failing due to missing Babel preset
- pnpm's stricter dependency isolation revealed this missing direct dependency
- Confirmed build-library now passes locally

* Lock changes

* Remove workspaces field from package.json

- pnpm uses pnpm-workspace.yaml instead of package.json workspaces field
- Fixes warning: 'The workspaces field in package.json is not supported by pnpm'
- Workspace configuration is already correctly defined in pnpm-workspace.yaml

* Does forcing a specific pnpm version help?

* Update pnpm version to 9.12.0 in CI workflows

- Fixes issues with pnpm v9.0 as mentioned in https://github.com/pnpm/pnpm/issues/6312
- Updates both ci.yml and code-size.yml workflows
- 9.12.0 matches the local version and is more stable
- Should resolve workspaces field warning and other pnpm issues

* Skip root-level Jest tests in CI due to configuration conflicts

- Root-level Jest config has compatibility issues with jest-environment-jsdom@29.7.0
- Different packages use different Jest versions causing testEnvironmentOptions errors
- Webamp package tests work fine with their specific Jest configuration
- This is the same issue we saw locally - pnpm's stricter isolation reveals these conflicts
- CI only needs webamp tests to pass for the migration validation

* Add missing Babel plugins for build-library

- Add @babel/plugin-proposal-object-rest-spread
- Add @babel/plugin-syntax-dynamic-import
- These were missing dependencies revealed by pnpm's stricter isolation
- Fixes build-library errors in CI

* Upgrade Jest to v29.7.0 to fix test environment issues

- Upgrade from Jest 27.5.1 to 29.7.0 to match webamp package version
- Add jest-environment-jsdom as direct dependency
- Fixes 'Cannot read properties of undefined (reading testEnvironmentOptions)' error
- pnpm's stricter isolation revealed version conflicts between packages
- Tests now run properly but some snapshots need updating due to format changes

* Re-enable Jest tests in CI

- Jest environment issues are now fixed with v29.7.0 upgrade
- Tests work properly with the updated configuration
- Some packages may have snapshot format changes but tests pass

* Clean up lock

* Clean up Yarn cruft

* Update snapshots

* Fix compressed size workflow for pnpm

- Add Node.js setup step (required for pnpm)
- Add pnpm install step to install dependencies before build
- Update checkout action from v2 to v4
- Ensure dependencies are available before running deploy script
2025-07-06 15:45:44 -07:00
Jordan Eldredge
bcaa8dfdc6 Try to speed up CI 2025-07-06 12:40:37 -07:00
Jordan Eldredge
9aa4f59db5 Turn moduleSideEffects back on 2025-07-06 12:30:38 -07:00
Jordan Eldredge
c78b1abe92 We know we have big chunks 2025-07-06 10:43:31 -07:00
Jordan Eldredge
d1f6ef0089 Fix reference to undefined variable 2025-07-06 10:43:06 -07:00
Jordan Eldredge
8eae420851 Format files 2025-07-06 10:36:55 -07:00
Jordan Eldredge
704112b333 Fix slow lint 2025-07-06 10:36:46 -07:00
Jordan Eldredge
274abd9090 Finish migration to inline action types 2025-07-06 10:32:33 -07:00
Jordan Eldredge
c6d0c2717c Clean up unused lock files 2025-07-06 10:02:41 -07:00
Jordan Eldredge
c0d4960dd3 Remove internet archive integration tests 2025-07-06 10:01:04 -07:00
Jordan Eldredge
48ef4eeff2 Inline action types 2025-07-06 09:48:33 -07:00
Jordan Eldredge
aa7ea12b41 Fix playlist scroll behavior 2025-07-05 18:35:43 -07:00
Jordan Eldredge
b508663db9 Clean up whitespace 2025-07-05 18:35:43 -07:00
Jordan Eldredge
eee17f0d25 Fix name 2025-07-05 18:35:43 -07:00
Jordan Eldredge
b1ec6460b2
Optimize ci performance (#1302)
* Improve rollup perf

* 🚀 Optimize CI performance with parallel jobs and caching

- Split monolithic build-and-test into 4 parallel jobs (setup, build, lint, test)
- Add dependency caching with actions/setup-node@v4 yarn cache
- Cache build artifacts between jobs to avoid rebuilding
- Upgrade to latest GitHub Actions (checkout@v4, setup-node@v4)
- Skip CI runs on documentation-only changes
- Optimize Jest with --maxWorkers=2 for better CI performance
- Add fail-safe caching for main-release job

Expected performance improvement: 40-50% faster CI runs

* Try to improve caching

* Revert "Try to improve caching"

This reverts commit 63d0abdca9.
2025-07-05 18:12:42 -07:00
Jordan Eldredge
833060e1ae Fix Jest 2025-07-05 16:53:03 -07:00
Jordan Eldredge
3e0417267a Avoid library build warnings 2025-07-05 16:32:55 -07:00
Jordan Eldredge
d4a841846c Switch back to streaming parsing of id3s 2025-07-05 16:17:03 -07:00
Jordan Eldredge
7b87c2302c Avoid ts warnings in vite build 2025-07-05 16:16:48 -07:00
Jordan Eldredge
b230cc66c5 Avoid commonjs warning in vite 2025-07-05 16:05:58 -07:00
Jordan Eldredge
1cb0991db7 Avoid trying to do parsing streaming while we wait for bug fix 2025-07-05 13:28:00 -07:00
Jordan Eldredge
1d39600284 Upgrade png optimizer 2025-07-04 21:46:40 -07:00
Jordan Eldredge
14c0d24a47 Avoid some memory leaks when disposing Webamp 2025-07-04 21:25:57 -07:00
Jordan Eldredge
e52900d4fc Add Milkdrop to docs example 2025-07-04 19:44:47 -07:00
Jordan Eldredge
7e159f2173 Move butterchurn loader docs to bundle size guide 2025-07-04 19:10:02 -07:00
Jordan Eldredge
bb98aba71a Prevent attempting to enter fullscreen mode on platforms where it's not supported 2025-07-04 18:28:42 -07:00
Jordan Eldredge
cfa67c4bc5 Update example to use new /butterchurn entrypoint 2025-07-04 17:29:31 -07:00
Jordan Eldredge
a86226bd6b Set viewport in examples 2025-07-04 16:59:08 -07:00
Jordan Eldredge
854e2c7998 Document webamp/butterchurn in changelog 2025-07-04 16:58:16 -07:00
Jordan Eldredge
f4321173df Improve butterchurn build 2025-07-04 16:43:13 -07:00
Jordan Eldredge
d060b9042b Throwing stuff at the wall, lol 2025-07-04 15:51:48 -07:00
Jordan Eldredge
50e6e21e2e Try adding Butterchurn bundle 2025-07-04 15:18:04 -07:00
Jordan Eldredge
e06d3b9b3e Add back polyfills 2025-07-04 11:02:02 -07:00
Jordan Eldredge
b5bf64a55d Inline music-metadata lazy loads to fix builds 2025-07-04 10:38:48 -07:00
Jordan Eldredge
1ba58c81e1 Aovid import types 2025-07-04 10:29:32 -07:00
Jordan Eldredge
9d0cdc6ed8 Ignore type error for now 2025-07-04 10:24:18 -07:00
Jordan Eldredge
90625614f0 Hide milkdrop context window if not enabled 2025-07-04 10:20:00 -07:00
Jordan Eldredge
b66667019c Fix types for metadata 2025-07-04 10:09:31 -07:00
Jordan Eldredge
e043a1b5b8 Try upgrading to music-metadata from music-metadata-browser 2025-07-04 10:06:13 -07:00
Jordan Eldredge
366e79525e Upgrade Redux devtools package 2025-06-29 22:59:10 -07:00
Jordan Eldredge
eb0898fe4e Add getPlayerMediaStatus instance method 2025-06-29 22:44:49 -07:00
Jordan Eldredge
3feae65585 Spelling 2025-06-29 21:34:31 -07:00
Jordan Eldredge
7ededf9bf5 Prevent default on scroll events Fixes #1301 2025-06-29 17:40:54 -07:00
Jordan Eldredge
067623811c Fix spelling 2025-06-29 17:27:03 -07:00
Jordan Eldredge
a2eb557599 Avoid warning when scrolling main window 2025-06-29 17:26:52 -07:00
Jordan Eldredge
2ea015c504 Avoid Redux dev tools warning 2025-06-29 17:26:25 -07:00
Jordan Eldredge
b63cec5cbd Fix recentering bug 2025-06-29 17:25:37 -07:00
Jordan Eldredge
a618b049eb Fix early resolution of renderWhenReady 2025-06-29 15:19:52 -07:00
Jordan Eldredge
4c07731d76 Fix more Winamp typos 2025-06-29 14:42:21 -07:00
Jordan Eldredge
ce483b8df5 Fix typos of Winamp when I meant Webamp 2025-06-29 14:22:19 -07:00
Jordan Eldredge
2be1e38bce Spelling typo fixes 2025-06-27 21:48:38 -07:00
Jordan Eldredge
c1d68b5946 Remove empty files 2025-06-25 15:23:30 -07:00
Jordan Eldredge
f4daf821c8 Add methods to toggle repeat and shuffle 2025-06-25 15:22:20 -07:00
Jordan Eldredge
afc8b038c8 Sketch of server rendered table 2025-06-25 15:14:08 -07:00
Jordan Eldredge
aa006df23e Docs improvements 2025-06-22 20:32:36 -07:00
Jordan Eldredge
6ad374934f Clarify how to get help 2025-06-22 17:36:51 -07:00
Jordan Eldredge
b5dc838b8a Build media session support into the NPM module 2025-06-20 08:43:04 -07:00
Jordan Eldredge
72b902e5f4 Add initial skin to multiple skins example 2025-06-19 21:14:45 -07:00
Jordan Eldredge
09c26754aa Bump to 2.1.2 2025-06-19 19:56:33 -07:00
Jordan Eldredge
333bb96c5d Improve songs and skins on docs page 2025-06-19 19:51:18 -07:00
Jordan Eldredge
469b136589 Work around setImmediate bug 2025-06-19 19:51:04 -07:00
Jordan Eldredge
a180155fb9 2.1.1 2025-06-19 14:10:35 -07:00
Jordan Eldredge
2d572d561e Revert "Make Milkdrop window opening on initialization work the same as it does for other windows"
This reverts commit 7ff8aab8af.
2025-06-19 14:03:22 -07:00
301 changed files with 31899 additions and 262450 deletions

100
.eslintrc
View file

@ -4,15 +4,11 @@
"jsx": true,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"experimentalObjectRestSpread": true
"jsx": true
}
},
"plugins": ["prettier"],
"plugins": ["prettier", "@typescript-eslint"],
"settings": {
"react": {
"version": "15.2"
},
"import/resolver": {
"node": {
"extensions": [".js", ".ts", ".tsx"]
@ -21,27 +17,85 @@
},
"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"
"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": "^_"
}
]
}
}

View file

@ -1,85 +1,125 @@
name: CI
on: [push]
on:
push:
pull_request:
jobs:
build-and-test:
# Main CI job - using Turborepo for dependency management
ci:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
node-version: ${{ matrix.node-version }}
version: 9.12.0
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "pnpm"
- name: Install Dependencies
run: yarn install --frozen-lockfile
- name: Build
run: pnpm install --frozen-lockfile
- name: Build all packages
run: |
yarn workspace ani-cursor build
yarn workspace webamp build
yarn workspace webamp build-library
- name: Lint
npx turbo build build-library
env:
NODE_ENV: production
- name: Lint and type-check
run: |
yarn lint
yarn workspace webamp type-check
- name: Run Unit Tests
npx turbo lint type-check
- name: Validate Grats generated files are up-to-date
run: ./scripts/validate-grats.sh
- name: Run 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:
# CI: true
# - name: Upload Screenshot Diffs
# if: failure()
# uses: actions/upload-artifact@v4
# with:
# 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
npx turbo test -- --maxWorkers=2
env:
NODE_ENV: test
- name: Cache build artifacts for release
uses: actions/cache@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
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
needs: [build-and-test]
needs: [ci]
permissions:
contents: read
id-token: write # Required for OIDC trusted publishing
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.0
- uses: actions/setup-node@v4
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
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Set version
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: |
echo "Setting version to 0.0.0-next-${RELEASE_COMMIT_SHA::7}"
yarn workspace webamp version --new-version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
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
env:
RELEASE_COMMIT_SHA: ${{ github.sha }}
- name: Build release version
- name: Set version for tagged release
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
working-directory: ./packages/webamp
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
# Note: This also triggers a build
run: |
npm publish ${TAG}
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
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

View file

@ -4,13 +4,23 @@ on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- 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"
- 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"

View file

@ -1,29 +0,0 @@
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,5 +1,7 @@
node_modules
.vscode
.parcel-cache
.idea
dist
parcel-bundle-reports
# Turborepo cache
.turbo

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,5 +0,0 @@
# 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

@ -3,28 +3,16 @@
# Webamp
A reimplementation of Winamp 2.9 in HTML5 and JavaScript with full skin support.
A reimplementation of Winamp 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).
## Add Webamp to Your Site
## Read the docs
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).
**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.**
## About This Repository
@ -32,11 +20,10 @@ Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in
- [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
- [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
- [`packages/skin-museum-client`](https://github.com/captbaritone/webamp/tree/master/packages/skin-museum-client): The front-end component of https://skins.webamp.org.
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
- [`packages/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
@ -78,6 +65,52 @@ 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,6 +8,8 @@ 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*",
],

View file

@ -1,6 +0,0 @@
#!/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

117
docs/typescript-checking.md Normal file
View file

@ -0,0 +1,117 @@
# 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

24
examples/lazy/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# 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?

5
examples/lazy/README.md Normal file
View file

@ -0,0 +1,5 @@
# `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.

13
examples/lazy/index.html Normal file
View file

@ -0,0 +1,13 @@
<!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

@ -0,0 +1,22 @@
{
"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"
}
}

57
examples/lazy/src/main.ts Normal file
View file

@ -0,0 +1,57 @@
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")!);

1
examples/lazy/src/vite-env.d.ts vendored Normal file
View file

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

View file

@ -0,0 +1,25 @@
{
"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,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
@ -9,7 +10,7 @@
<!-- Webamp will attempt to center itself within this div -->
</div>
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
@ -19,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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},

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 and skin 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 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,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
@ -10,50 +11,11 @@
</div>
<script type="module">
/**
* Webamp now includes an ESModule build, so you can import it directly. I
* haven't validated full backwards compatibility with all the ways people
* can import Webamp, so I haven't shipped this as the default build yet,
* but it's on NPM as: `webamp@0.0.0-next-361ce79`.
*
* Changelog since the version you have can be found here:
* https://github.com/captbaritone/webamp/blob/master/packages/webamp/CHANGELOG.md
* 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 is not being actively maintained, but it is still works
* great. Before it went into maintenance mode Jordan Berg (different
* Jordan) cut a beta release of a version with the faster/more secure
* eel->Wasm compiler that I wrote, so we use `butterchurn@3.0.0-beta.3`.
*
* Blog post about the eel->Wasm compiler here:
* https://jordaneldredge.com/blog/speeding-up-winamps-music-visualizer-with-webassembly/
*
* This version is still using AMD modules, so it will write the export to
* `window.butterchurn`. This is a pretty chunky files, so you way want to
* find a way to lazy load it inside `importButterchurn` below.
* Unfortunately, it's not an ES module, so I wasn't able to call
* `import()` on it without some kind of bundler.
*/
import "https://unpkg.com/butterchurn@3.0.0-beta.3/dist/butterchurn.min.js";
const butterchurn = window.butterchurn;
/**
* This module, `butterchurn-presets@3.0.0-beta.4` contains a curated set
* of awesome Butterchurn presets that have been packaged up to work with
* the new compiler. This module is also packaged as an AMD module, so
* when imported without a bundler it will write the export to `window`. I
* think the package was never that thoughtfully built, so the export name
* is, confusingly `window.base`. If that's a problem, using a bundler
* might help.
*
* As audio plays, Webamp will randomly cycle through these presets with a
* cool transition effect. You can also press "l" while the Milkdrop
* window is open to open Milkdrop's preset selection menu.
*/
import "https://unpkg.com/butterchurn-presets@3.0.0-beta.4/dist/base.js";
const butterchurnPresets = window.base.default;
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
@ -63,31 +25,11 @@
},
// 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
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
__butterchurnOptions: {
importButterchurn: () => Promise.resolve(butterchurn),
getPresets: () => {
return Object.entries(butterchurnPresets).map(([name, preset]) => {
return { name, butterchurnPresetObject: preset };
});
},
},
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,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>

View file

@ -0,0 +1,13 @@
# 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

@ -0,0 +1,45 @@
<!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,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
@ -13,10 +14,15 @@
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.
// NOTE: These URLs must be served with the correct CORs headers.
// https://docs.webamp.org/docs/guides/cors
//
// 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",
@ -26,6 +32,9 @@
name: "Mac OSX v1.5 (Aqua)",
},
],
initialSkin: {
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
},
initialTracks: [
{
metaData: {

View file

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
@ -25,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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
@ -36,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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
duration: 322.612245,
},
@ -47,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://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/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

@ -1,5 +1,5 @@
[build]
command = "yarn deploy"
command = "pnpm run 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 = "20.9.0"
NODE_VERSION = "22.11.0"

62079
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,47 +1,52 @@
{
"name": "webamp-monorepo",
"private": true,
"workspaces": [
"packages/*",
"examples/*"
],
"packageManager": "pnpm@9.12.0",
"overrides": {
"graphql": "16.8.1"
},
"engines": {
"node": ">=16.0.0"
"node": ">=22.0.0"
},
"scripts": {
"test": "jest",
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
"deploy": "sh deploy.sh",
"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",
"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",
"@typescript-eslint/parser": "^7.1.0",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.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": "^27.5.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^2.3.2",
"puppeteer": "^22.2.0",
"stream-browserify": "^3.0.0",
"typescript": "^5.3.3"
"turbo": "^2.5.4",
"typescript": "^5.6.2"
},
"prettier": {
"trailingComma": "es5"
},
"jest": {
"projects": [
"config/jest.*.js"
]
},
"dependencies": {},
"version": "0.0.0-next-87012d8d"
"version": "0.0.0-next-87012d8d",
"pnpm": {
"patchedDependencies": {
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
}
}
}

View file

@ -8,12 +8,6 @@ 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
```
@ -41,4 +35,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

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

View file

@ -2,14 +2,24 @@
"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",
@ -21,6 +31,8 @@
"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"
},
@ -28,7 +40,9 @@
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.20.0",
"typescript": "^5.3.3"
"@swc/jest": "^0.2.24",
"@types/node": "^24.0.10",
"typescript": "^5.6.2"
},
"dependencies": {
"byte-data": "18.1.1",
@ -38,6 +52,13 @@
"modulePathIgnorePatterns": [
"dist"
],
"testEnvironment": "jsdom"
"testEnvironment": "jsdom",
"extensionsToTreatAsEsm": [".ts"],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.(t|j)sx?$": ["@swc/jest"]
}
}
}

View file

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

View file

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

View file

@ -1,8 +1,10 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"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'. */,
"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 */,
"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'. */,
@ -24,7 +26,9 @@
"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

@ -1,9 +0,0 @@
# 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

@ -1,121 +0,0 @@
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

@ -1,11 +0,0 @@
{
"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,24 +1,28 @@
module.exports = {
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"],
extends: ["plugin:@typescript-eslint/recommended"],
rules: {
// "no-console": "warn",
// Disable rules that conflict with the project's style
"@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",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
// 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",
},
ignorePatterns: ["dist/**"],
};

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

@ -1,3 +1,3 @@
export const searchIndex = {
export const client = {
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, buffer);
fs.writeFileSync(tempFile, new Uint8Array(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, buffer);
fs.writeFileSync(tempFile, new Uint8Array(buffer));
const tempScreenshotPath = temp.path({ suffix: ".png" });
const logLines: string[] = [];
@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
await setHashesForSkin(skin);
// Disable while we figure out our quota
// await Skins.updateSearchIndex(ctx, md5);
await Skins.updateSearchIndex(ctx, md5);
return { md5, status: "ADDED", skinType: "CLASSIC" };
}

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
import { searchIndex } from "../../algolia";
import { client } from "../../algolia";
import { createYogaInstance } from "../../app/graphql/yoga";
import { YogaServerInstance } from "graphql-yoga";
jest.mock("../../s3");
@ -30,6 +30,10 @@ beforeEach(async () => {
await knex.seed.run();
});
afterAll(async () => {
await knex.destroy();
});
function gql(templateString: TemplateStringsArray): string {
return templateString[0];
}
@ -123,35 +127,35 @@ describe("Query.skins", () => {
`
);
expect(data.skins).toMatchInlineSnapshot(`
Object {
{
"count": 6,
"nodes": Array [
Object {
"nodes": [
{
"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,
@ -179,15 +183,15 @@ describe("Query.skins", () => {
{ first: 2, offset: 1 }
);
expect(data.skins).toMatchInlineSnapshot(`
Object {
{
"count": 6,
"nodes": Array [
Object {
"nodes": [
{
"filename": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
{
"filename": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
@ -327,9 +331,10 @@ test("Mutation.mark_skin_nsfw", async () => {
type: "MARKED_SKIN_NSFW",
md5: "a_fake_md5",
});
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
{ nsfw: true, objectID: "a_fake_md5" },
]);
expect(client.partialUpdateObjects).toHaveBeenCalledWith({
indexName: "test-index",
objects: [{ nsfw: true, objectID: "a_fake_md5" }],
});
expect(data).toEqual({ mark_skin_nsfw: true });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");

View file

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

View file

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

View file

@ -69,19 +69,34 @@ export function id(skin: ISkin): ID {
* has been uploaded under multiple names. Here we just pick one.
* @gqlField
*/
export function filename(
export async 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> {
return skin.filename(normalize_extension);
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}`;
}
/**

View file

@ -1,4 +1,3 @@
import { Ctx } from "..";
import UserContext from "../../../data/UserContext.js";
import { Rating, ReviewRow } from "../../../types";
import { ISkin } from "./CommonSkinResolver";

View file

@ -4,14 +4,9 @@ import UserContext from "../../../data/UserContext";
import ClassicSkinResolver from "./ClassicSkinResolver";
import { ISkin } from "./CommonSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
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() {
throw new Error("This is a stub.");

View file

@ -1,5 +1,6 @@
# 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.
@ -92,6 +93,10 @@ 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.
@ -161,7 +166,17 @@ type ArchiveFile {
serverless Cloudflare function which tries to exctact the file on the fly.
It may not work for all files.
"""
url: String @semanticNonNull
url: String
}
"""Connection for bulk download skin metadata"""
type BulkDownloadConnection {
"""Estimated total size in bytes (approximation for progress indication)"""
estimatedSizeBytes: String @semanticNonNull
"""List of skin metadata for bulk download"""
nodes: [Skin!] @semanticNonNull
"""Total number of skins available for download"""
totalCount: Int @semanticNonNull
}
"""A classic Winamp skin"""
@ -177,6 +192,10 @@ 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.
@ -307,6 +326,10 @@ 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.
@ -384,6 +407,8 @@ 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
@ -411,7 +436,7 @@ type Query {
"""
node(id: ID!): Node
"""
Search the database using the Algolia search index used by the Museum.
Search the database using SQLite's FTS (full text search) index.
Useful for locating a particular skin.
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,330 @@
"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

@ -0,0 +1,103 @@
"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

@ -0,0 +1,344 @@
"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

@ -0,0 +1,198 @@
"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

@ -0,0 +1,233 @@
"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

@ -0,0 +1,97 @@
"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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,161 @@
"use client";
import {
HEADING_HEIGHT,
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "../../../legacy-client/src/constants.js";
import {
useScrollbarWidth,
useWindowSize,
} from "../../../legacy-client/src/hooks.js";
import React, { useEffect, useMemo, useState } from "react";
import { FixedSizeGrid as Grid } from "react-window";
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? <>{children}</> : null;
}
export default function WrappedTable({ initialSkins, skinCount }) {
return (
<ClientOnly>
<Table initialSkins={initialSkins} skinCount={skinCount} />
</ClientOnly>
);
}
function Table({ initialSkins, skinCount }) {
const skins = initialSkins;
const scale = 0.5; // This can be adjusted based on your needs
function getSkinData(data: {
columnIndex: number;
rowIndex: number;
columnCount: number;
}) {
const index = data.rowIndex * columnCount + data.columnIndex;
const skin = skins[index];
return { requestToken: skin?.md5, skin };
}
const scrollbarWidth = useScrollbarWidth();
const { windowWidth: windowWidthWithScrollabar, windowHeight } =
useWindowSize();
const { columnWidth, rowHeight, columnCount } = getTableDimensions(
windowWidthWithScrollabar - scrollbarWidth,
scale
);
function Cell(props) {
const index = props.rowIndex * columnCount + props.columnIndex;
const skin = skins[index];
if (skin == null) {
if (index < skinCount) {
// Fetch more skins!
}
return <div style={props.style}></div>;
}
if (skin == null) {
return <div style={props.style}>Loading...</div>;
}
const imageUrl = `https://r2.webampskins.org/screenshots/${skin.md5}.png`;
return (
<div style={props.style}>
<img
src={imageUrl}
style={{ width: props.data.width, height: props.data.height }}
/>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<SkinTableUnbound
columnCount={columnCount}
columnWidth={columnWidth}
rowHeight={rowHeight}
windowHeight={windowHeight}
windowWidth={windowWidthWithScrollabar}
skinCount={skinCount}
getSkinData={getSkinData}
Cell={Cell}
/>
</div>
);
}
const getTableDimensions = (windowWidth: number, scale: number) => {
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * scale));
const columnWidth = windowWidth / columnCount; // TODO: Consider flooring this to get things aligned to the pixel
const rowHeight = columnWidth * SKIN_RATIO;
return { columnWidth, rowHeight, columnCount };
};
function SkinTableUnbound({
columnCount,
columnWidth,
rowHeight,
windowHeight,
skinCount,
windowWidth,
getSkinData,
Cell,
}) {
function itemKey({ columnIndex, rowIndex }) {
const { requestToken, data: skin } = getSkinData({
columnIndex,
rowIndex,
columnCount,
});
if (skin == null && requestToken == null) {
return `empty-cell-${columnIndex}-${rowIndex}`;
}
return skin ? skin.hash : `unfectched-index-${requestToken}`;
}
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
React.useLayoutEffect(() => {
if (gridRef.current == null) {
return;
}
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
}, [skinCount]);
React.useLayoutEffect(() => {
if (gridRef.current == null) {
return;
}
const itemRow = Math.floor(itemRef.current / columnCount);
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: rowHeight * itemRow });
}, [rowHeight, columnCount]);
const onScroll = useMemo(() => {
const half = Math.round(columnCount / 2);
return (scrollData) => {
itemRef.current =
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
};
}, [columnCount, rowHeight]);
return (
<div id="infinite-skins" style={{ marginTop: HEADING_HEIGHT }}>
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={{ columnCount, width: columnWidth, height: rowHeight }}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight - HEADING_HEIGHT}
rowCount={Math.ceil(skinCount / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowCount={5}
onScroll={onScroll}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
</div>
);
}

View file

@ -0,0 +1,17 @@
import * as Skins from "../../../data/skins";
import Table from "./Table";
export default async function TablePage() {
const skins = await Skins.getMuseumPage({
offset: 0,
first: 100,
});
const skinCount = await Skins.getClassicSkinCount();
return (
<div className="flex flex-col gap-4">
<Table initialSkins={skins} skinCount={skinCount} />
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -35,11 +35,14 @@ import { setHashesForSkin } from "./skinHash";
import * as S3 from "./s3";
import { generateDescription } from "./services/openAi";
import KeyValue from "./data/KeyValue";
import { postToBluesky } from "./tasks/bluesky";
import { computeSkinRankings } from "./tasks/computeScrollRanking";
async function withHandler(
cb: (handler: DiscordEventHandler) => Promise<void>
) {
const handler = new DiscordEventHandler();
await handler._clientPromise; // Ensure client is initialized
try {
await cb(handler);
} finally {
@ -81,21 +84,30 @@ program
.argument("[md5]", "md5 of the skin to share")
.option("-t, --twitter", "Share on Twitter")
.option("-i, --instagram", "Share on Instagram")
.option("-b, --bluesky", "Share on Bluesky")
.option("-m, --mastodon", "Share on Mastodon")
.action(async (md5, { twitter, instagram, mastodon }) => {
if (!twitter && !instagram && !mastodon) {
throw new Error("Expected at least one of --twitter or --instagram");
}
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
await withDiscordClient(async (client) => {
if (twitter) {
await tweet(client, md5);
return;
}
if (instagram) {
await insta(client, md5);
return;
}
if (mastodon) {
await postToMastodon(client, md5);
return;
}
if (bluesky) {
await postToBluesky(client, md5);
return;
}
throw new Error(
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
);
});
});
@ -161,7 +173,7 @@ program
console.log("====================================");
}
if (purge) {
// cat purge | xargs -I {} yarn cli skin --purge {}
// cat purge | xargs -I {} pnpm cli skin --purge {}
await Skins.deleteSkin(md5);
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
const purged = new Set(purgedArr);
@ -220,14 +232,21 @@ program
if (screenshot) {
const buffer = fs.readFileSync(filePath);
const md5 = md5Buffer(buffer);
const tempPath = temp.path({ suffix: ".png" });
const tempSkinFile = temp.path({ suffix: ".wsz" });
const tempScreenshotPath = temp.path({ suffix: ".png" });
// Write buffer to temporary file as Puppeteer's uploadFile expects a file path
fs.writeFileSync(tempSkinFile, new Uint8Array(buffer));
await Shooter.withShooter(
async (shooter: Shooter) => {
await shooter.takeScreenshot(buffer, tempPath, { md5 });
await shooter.takeScreenshot(tempSkinFile, tempScreenshotPath, {
md5,
});
},
(message: string) => console.log(message)
);
console.log("Screenshot complete", tempPath);
console.log("Screenshot complete", tempScreenshotPath);
}
});
@ -291,6 +310,14 @@ program
console.table([await Skins.getStats()]);
});
program
.command("compute-scroll-ranking")
.description("Analyze user event data and compute skin ranking scores.")
.action(async () => {
const rankings = await computeSkinRankings();
console.log(JSON.stringify(rankings, null, 2));
});
program
.command("process-uploads")
.description("Process any unprocessed user uploads.")
@ -431,7 +458,7 @@ program
);
const md5s = rows.map((row) => row.md5);
console.log(md5s.length);
console.log(await Skins.updateSearchIndexs(ctx, md5s));
console.log(await Skins.updateSearchIndexes(ctx, md5s));
}
if (refreshContentHash) {
const ctx = new UserContext();
@ -455,7 +482,6 @@ program
"missingModernSkins"
);
const missingModernSkinsSet = new Set(missingModernSkins);
const skins = {};
for (const md5 of missingModernSkins!) {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {

View file

@ -29,10 +29,12 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
// Used for session encryption
export const SECRET = env("SECRET");
export const NODE_ENV = env("NODE_ENV") || "production";
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
function env(key: string): string {
const value = process.env[key];
if (value == null) {
if (!value) {
throw new Error(`Expected an environment variable "${key}"`);
}
return value;

View file

@ -7,7 +7,6 @@ import FileInfoModel from "./FileInfoModel";
import { ISkin } from "../api/graphql/resolvers/CommonSkinResolver";
import SkinResolver from "../api/graphql/resolvers/SkinResolver";
import { Int } from "grats";
import { Ctx } from "../api/graphql";
export type ArchiveFileDebugData = {
row: ArchiveFileRow;

View file

@ -6,7 +6,6 @@ import { knex } from "../db";
import { fetchMetadata, fetchTasks } from "../services/internetArchive";
import { ISkin } from "../api/graphql/resolvers/CommonSkinResolver";
import SkinResolver from "../api/graphql/resolvers/SkinResolver";
import { Ctx } from "../api/graphql";
const IA_URL = /^(https:\/\/)?archive.org\/details\/([^/]+)\/?/;
@ -131,7 +130,7 @@ export default class IaItemModel {
}
try {
return JSON.parse(this.row.metadata).files;
} catch (e) {
} catch (_e) {
console.warn("Could not parse", this.row.metadata);
return [];
}

View file

@ -0,0 +1,29 @@
import { knex } from "../db";
import { randomUUID } from "crypto";
export default class SessionModel {
static async create(): Promise<string> {
const sessionId = randomUUID();
const startTime = Date.now();
await knex("session").insert({
id: sessionId,
start_time: startTime,
});
return sessionId;
}
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
await knex("session_skin").insert({
session_id: sessionId,
skin_md5: skinMd5,
});
}
static async getIncludedSkinCount(sessionId: string): Promise<number> {
const result = await knex("session_skin")
.where({ session_id: sessionId })
.count("* as count")
.first();
return result ? (result.count as number) : 0;
}
}

View file

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

View file

@ -7,7 +7,6 @@ import SkinModel from "./SkinModel";
import { Int } from "grats";
import { ISkin } from "../api/graphql/resolvers/CommonSkinResolver";
import SkinResolver from "../api/graphql/resolvers/SkinResolver";
import { Ctx } from "../api/graphql";
export type TweetDebugData = {
row: TweetRow;

View file

@ -7,6 +7,10 @@ beforeEach(async () => {
await knex.seed.run();
});
afterAll(async () => {
await knex.destroy();
});
test("fromMd5", async () => {
const ctx = new UserContext();
const files = await ArchiveFileModel.fromMd5(ctx, "a_fake_md5");

View file

@ -7,6 +7,10 @@ beforeEach(async () => {
await knex.seed.run();
});
afterAll(async () => {
await knex.destroy();
});
test("fromMd5", async () => {
const ctx = new UserContext();
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");

View file

@ -7,6 +7,10 @@ beforeEach(async () => {
await knex.migrate.latest();
});
afterAll(async () => {
await knex.destroy();
});
test("empty", async () => {
expect(await Skins.getAllClassicSkins()).toEqual([]);
expect(await Skins.getClassicSkinCount()).toBe(0);
@ -39,28 +43,28 @@ describe("seeded", () => {
});
test("getAllClassicSkins", async () => {
expect(await Skins.getAllClassicSkins()).toMatchInlineSnapshot(`
Array [
Object {
[
{
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
},
Object {
{
"fileName": "path.wsz",
"md5": "a_fake_md5",
},
Object {
{
"fileName": "nsfw.wsz",
"md5": "a_nsfw_md5",
},
Object {
{
"fileName": "rejected.wsz",
"md5": "a_rejected_md5",
},
Object {
{
"fileName": "tweeted.wsz",
"md5": "a_tweeted_md5",
},
Object {
{
"fileName": "approved.wsz",
"md5": "an_approved_md5",
},
@ -75,38 +79,38 @@ describe("seeded", () => {
});
test("getAllClassicScreenshotUrls", async () => {
expect(await Skins.getAllClassicScreenshotUrls()).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,
@ -118,33 +122,33 @@ describe("seeded", () => {
test("getMuseumPage", async () => {
expect(await Skins.getMuseumPage({ offset: 0, first: 10 }))
.toMatchInlineSnapshot(`
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,
@ -167,7 +171,7 @@ describe("seeded", () => {
});
test("getStats", async () => {
expect(await Skins.getStats()).toMatchInlineSnapshot(`
Object {
{
"approved": 2,
"nsfw": 1,
"rejected": 1,

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