mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 02:15:01 +00:00
Compare commits
187 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe3235d51 | ||
|
|
54dbe369d4 | ||
|
|
bce845962f | ||
|
|
4b784b6eaf | ||
|
|
52f12327fa | ||
|
|
1d10f7a1f4 | ||
|
|
99f06d3bfc | ||
|
|
162025f8a0 | ||
|
|
f600fb0344 | ||
|
|
04c396ed1f | ||
|
|
f74d7a6cdf | ||
|
|
1fb930cd63 | ||
|
|
8d4ff41f42 | ||
|
|
e062a51a88 | ||
|
|
0895f9191f | ||
|
|
3b4e5b17c3 | ||
|
|
91618c9c6b | ||
|
|
6c732f8e24 | ||
|
|
d87cb6ffa3 | ||
|
|
6997c852f9 | ||
|
|
61476591f8 | ||
|
|
33003a8f8f | ||
|
|
50a7c2df49 | ||
|
|
1f875a6155 | ||
|
|
26a6002ce8 | ||
|
|
bd6c978d79 | ||
|
|
18ee5418b6 | ||
|
|
af21934296 | ||
|
|
4b793c30b2 | ||
|
|
a6b0350a00 | ||
|
|
964a7c5f2f | ||
|
|
b00e359a78 | ||
|
|
d159308352 | ||
|
|
d687f4b06c | ||
|
|
8fa7701b47 | ||
|
|
1da77a640a | ||
|
|
642fb964d6 | ||
|
|
8358d4843c | ||
|
|
bbd1d1224e | ||
|
|
b672de2515 | ||
|
|
014c8eab28 | ||
|
|
a56cbc54c5 | ||
|
|
8efe121f3c | ||
|
|
c778464c42 | ||
|
|
d6245c7c7e | ||
|
|
f1339901e6 | ||
|
|
811fc977c4 | ||
|
|
7afe3bd45b | ||
|
|
608242b200 | ||
|
|
f3054192e6 | ||
|
|
0b2ff44b1c | ||
|
|
52ff84d29b | ||
|
|
fbe3a0090f | ||
|
|
0705e9d89e | ||
|
|
340e2249ae | ||
|
|
4b405bc831 | ||
|
|
50d5dbbf4f | ||
|
|
96ffdcda59 | ||
|
|
e5ed88c8ec | ||
|
|
3c882550e3 | ||
|
|
6434ecc626 | ||
|
|
a20bab1877 | ||
|
|
a0cecb8f93 | ||
|
|
0608b2f9c6 | ||
|
|
27ba138a3e | ||
|
|
cb651adfaf | ||
|
|
224b4b8058 | ||
|
|
f35f1242ca | ||
|
|
acff24b7bb | ||
|
|
e99b2ab6f7 | ||
|
|
a30ab82ccc | ||
|
|
eaba9667e2 | ||
|
|
41cfbbb63c | ||
|
|
6f8f85c865 | ||
|
|
e82db4cddd | ||
|
|
0d7cb8285f | ||
|
|
a4dec85406 | ||
|
|
5e88d4e37b | ||
|
|
9b03dcd077 | ||
|
|
cb2948c2d3 | ||
|
|
b7d759618e | ||
|
|
910995d469 | ||
|
|
35e6128224 | ||
|
|
b02fc7a5c6 | ||
|
|
161c14b598 | ||
|
|
7cdc35b93a | ||
|
|
19668851d6 | ||
|
|
b76491001c | ||
|
|
dbc64a8287 | ||
|
|
9f789ae2ba | ||
|
|
95e96ddb02 | ||
|
|
12c31f65ae | ||
|
|
af1f294ada | ||
|
|
a06485f1be | ||
|
|
a4f14d9a86 | ||
|
|
4ead993d64 | ||
|
|
9d88134988 | ||
|
|
157f8d28df | ||
|
|
c55212b363 | ||
|
|
15b15d081a | ||
|
|
51859506db | ||
|
|
2dee12e9e0 | ||
|
|
daf06567b0 | ||
|
|
3a1ec73825 | ||
|
|
b090082dfa | ||
|
|
6ce866f48c | ||
|
|
26e8e6dfeb | ||
|
|
8425adaeff | ||
|
|
c2c067588d | ||
|
|
f45736c0b6 | ||
|
|
28c36e5141 | ||
|
|
99e4ae4f90 | ||
|
|
ce8c4b1bcc | ||
|
|
fee8030882 | ||
|
|
46ad06054e | ||
|
|
60c94b4f00 | ||
|
|
e641c61b64 | ||
|
|
f471cd1e69 | ||
|
|
aef14f205d | ||
|
|
dbf54596e4 | ||
|
|
f2fd00b65b | ||
|
|
b99ea291eb | ||
|
|
8abcf9d139 | ||
|
|
f9f892bde1 | ||
|
|
08ec7ce69f | ||
|
|
bcaa8dfdc6 | ||
|
|
9aa4f59db5 | ||
|
|
c78b1abe92 | ||
|
|
d1f6ef0089 | ||
|
|
8eae420851 | ||
|
|
704112b333 | ||
|
|
274abd9090 | ||
|
|
c6d0c2717c | ||
|
|
c0d4960dd3 | ||
|
|
48ef4eeff2 | ||
|
|
aa7ea12b41 | ||
|
|
b508663db9 | ||
|
|
eee17f0d25 | ||
|
|
b1ec6460b2 | ||
|
|
833060e1ae | ||
|
|
3e0417267a | ||
|
|
d4a841846c | ||
|
|
7b87c2302c | ||
|
|
b230cc66c5 | ||
|
|
1cb0991db7 | ||
|
|
1d39600284 | ||
|
|
14c0d24a47 | ||
|
|
e52900d4fc | ||
|
|
7e159f2173 | ||
|
|
bb98aba71a | ||
|
|
cfa67c4bc5 | ||
|
|
a86226bd6b | ||
|
|
854e2c7998 | ||
|
|
f4321173df | ||
|
|
d060b9042b | ||
|
|
50e6e21e2e | ||
|
|
e06d3b9b3e | ||
|
|
b5bf64a55d | ||
|
|
1ba58c81e1 | ||
|
|
9d0cdc6ed8 | ||
|
|
90625614f0 | ||
|
|
b66667019c | ||
|
|
e043a1b5b8 | ||
|
|
366e79525e | ||
|
|
eb0898fe4e | ||
|
|
3feae65585 | ||
|
|
7ededf9bf5 | ||
|
|
067623811c | ||
|
|
a2eb557599 | ||
|
|
2ea015c504 | ||
|
|
b63cec5cbd | ||
|
|
a618b049eb | ||
|
|
4c07731d76 | ||
|
|
ce483b8df5 | ||
|
|
2be1e38bce | ||
|
|
c1d68b5946 | ||
|
|
f4daf821c8 | ||
|
|
afc8b038c8 | ||
|
|
aa006df23e | ||
|
|
6ad374934f | ||
|
|
b5dc838b8a | ||
|
|
72b902e5f4 | ||
|
|
09c26754aa | ||
|
|
333bb96c5d | ||
|
|
469b136589 | ||
|
|
a180155fb9 | ||
|
|
2d572d561e |
301 changed files with 31899 additions and 262450 deletions
100
.eslintrc
100
.eslintrc
|
|
@ -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": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
162
.github/workflows/ci.yml
vendored
162
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
.github/workflows/code-size.yml
vendored
24
.github/workflows/code-size.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
29
.github/workflows/ia-integration-tests.yml
vendored
29
.github/workflows/ia-integration-tests.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
node_modules
|
||||
.vscode
|
||||
.parcel-cache
|
||||
.idea
|
||||
dist
|
||||
parcel-bundle-reports
|
||||
|
||||
# Turborepo cache
|
||||
.turbo
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "@parcel/config-default"
|
||||
}
|
||||
147392
.yarn/releases/yarn-1.22.10.cjs
vendored
147392
.yarn/releases/yarn-1.22.10.cjs
vendored
File diff suppressed because one or more lines are too long
5
.yarnrc
5
.yarnrc
|
|
@ -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"
|
||||
67
README.md
67
README.md
|
|
@ -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).
|
||||
|
||||
[](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
|
||||
|
|
|
|||
|
|
@ -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*",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
117
docs/typescript-checking.md
Normal 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
24
examples/lazy/.gitignore
vendored
Normal 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
5
examples/lazy/README.md
Normal 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
13
examples/lazy/index.html
Normal 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>
|
||||
22
examples/lazy/package.json
Normal file
22
examples/lazy/package.json
Normal 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
57
examples/lazy/src/main.ts
Normal 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
1
examples/lazy/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
examples/lazy/tsconfig.json
Normal file
25
examples/lazy/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
13
examples/multipleMilkdropPresets/README.md
Normal file
13
examples/multipleMilkdropPresets/README.md
Normal 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
|
||||
```
|
||||
45
examples/multipleMilkdropPresets/index.html
Executable file
45
examples/multipleMilkdropPresets/index.html
Executable 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
62079
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
};
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseAni } from "./parser";
|
||||
import { parseAni } from "./parser.js";
|
||||
|
||||
type AniCursorImage = {
|
||||
frames: {
|
||||
|
|
|
|||
|
|
@ -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. */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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/**"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export const searchIndex = {
|
||||
export const client = {
|
||||
partialUpdateObjects: jest.fn(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Ctx } from "..";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
import { Rating, ReviewRow } from "../../../types";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from "../App";
|
||||
|
||||
export default function Layout() {
|
||||
return <App />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal 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";
|
||||
};
|
||||
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal file
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal 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 "{inputValue}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal file
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal file
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal file
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
||||
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal file
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal 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;
|
||||
}
|
||||
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal file
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal file
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal file
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal 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} />;
|
||||
}
|
||||
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal file
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal 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 */
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal file
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal file
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
161
packages/skin-database/app/(modern)/table/Table.tsx
Normal file
161
packages/skin-database/app/(modern)/table/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/skin-database/app/(modern)/table/page.tsx
Normal file
17
packages/skin-database/app/(modern)/table/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
483
packages/skin-database/app/bulk-download/page.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import App from "../App";
|
||||
import App from "./App";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
const DESCRIPTION =
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
29
packages/skin-database/data/SessionModel.ts
Normal file
29
packages/skin-database/data/SessionModel.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { knex } from "../db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default class SessionModel {
|
||||
static async create(): Promise<string> {
|
||||
const sessionId = randomUUID();
|
||||
const startTime = Date.now();
|
||||
await knex("session").insert({
|
||||
id: sessionId,
|
||||
start_time: startTime,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
|
||||
await knex("session_skin").insert({
|
||||
session_id: sessionId,
|
||||
skin_md5: skinMd5,
|
||||
});
|
||||
}
|
||||
|
||||
static async getIncludedSkinCount(sessionId: string): Promise<number> {
|
||||
const result = await knex("session_skin")
|
||||
.where({ session_id: sessionId })
|
||||
.count("* as count")
|
||||
.first();
|
||||
return result ? (result.count as number) : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue