mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 10:15:31 +00:00
Compare commits
286 commits
v0.0.0-nex
...
master
| 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 | ||
|
|
d5ea998198 | ||
|
|
8b218e8e67 | ||
|
|
62c5af1dfb | ||
|
|
1fb4a4fb5b | ||
|
|
4ad4ea74eb | ||
|
|
fd12770ad5 | ||
|
|
6432753d20 | ||
|
|
415a06f80f | ||
|
|
a0f5cd2358 | ||
|
|
23c34434f3 | ||
|
|
5a312bd9c6 | ||
|
|
7ff8aab8af | ||
|
|
f0f8e88dd0 | ||
|
|
dc59239a83 | ||
|
|
91e6bd3902 | ||
|
|
359b3258ed | ||
|
|
624573a4d9 | ||
|
|
f860e72971 | ||
|
|
d007dadd5a | ||
|
|
1dc8c15bbb | ||
|
|
978c0fcabd | ||
|
|
199acfc23b | ||
|
|
361ce79faa | ||
|
|
fba45357c0 | ||
|
|
c643c0cc95 | ||
|
|
0bb70dee8e | ||
|
|
a9ee896053 | ||
|
|
74061b782e | ||
|
|
9101dc94ce | ||
|
|
b761f81c21 | ||
|
|
ac497964b0 | ||
|
|
33ca103c10 | ||
|
|
186129ecb9 | ||
|
|
45189adcde | ||
|
|
0fd0cbab4a | ||
|
|
564309ecad | ||
|
|
53335e8f80 | ||
|
|
bffcefa5f6 | ||
|
|
7dd7f139a9 | ||
|
|
963f375f33 | ||
|
|
e7ca8edf78 | ||
|
|
e8c677d97b | ||
|
|
77e022aa56 | ||
|
|
cdcf240a96 | ||
|
|
b794ca333c | ||
|
|
12ec93d2d2 | ||
|
|
1761e9101f | ||
|
|
c47498120c | ||
|
|
6b4e227aad | ||
|
|
71ca6d9230 | ||
|
|
b64a007d0f | ||
|
|
9ccaa396dc | ||
|
|
5a3b08e621 | ||
|
|
3904636e8a | ||
|
|
2a055064a1 | ||
|
|
71f754f9f9 | ||
|
|
fa9d55b9af | ||
|
|
1cc0721cba | ||
|
|
f89dc00fa0 | ||
|
|
b7692aca80 | ||
|
|
ec77debbd6 | ||
|
|
15b0312cb7 | ||
|
|
f90c72d53b | ||
|
|
e82b06b5a7 | ||
|
|
a5bb19c852 | ||
|
|
71600a9fca | ||
|
|
f5065235b7 | ||
|
|
f656aba7d9 | ||
|
|
9510f7b37f | ||
|
|
b2226e84c6 | ||
|
|
7e61c5e351 | ||
|
|
020ad85401 | ||
|
|
925760af1d | ||
|
|
784caeac54 | ||
|
|
eb6b28b326 | ||
|
|
faee5410ac | ||
|
|
68b8b22a49 | ||
|
|
9af4c7336c | ||
|
|
b01cdc5876 | ||
|
|
afff5d63d2 | ||
|
|
e242054ec6 | ||
|
|
9a12a61c08 | ||
|
|
2a8219299f | ||
|
|
b06490dd74 | ||
|
|
6d3b7fc367 | ||
|
|
67e6cdf89d | ||
|
|
bdd5f505e7 | ||
|
|
8083662f0d | ||
|
|
66fb476f36 | ||
|
|
f25242bf00 | ||
|
|
7ef3a309ed | ||
|
|
37134c7d31 | ||
|
|
50c386d6b2 | ||
|
|
56a7402e4c | ||
|
|
7f284263fb | ||
|
|
38ee567f97 | ||
|
|
2b5e6bc431 | ||
|
|
bb45f513bb | ||
|
|
2396030248 |
432 changed files with 36202 additions and 269078 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": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
.github/workflows/ci.yml
vendored
161
.github/workflows/ci.yml
vendored
|
|
@ -1,84 +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@v1
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
- 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 --ignore-scripts
|
||||
- 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
|
||||
run: |
|
||||
yarn lint
|
||||
yarn workspace webamp type-check
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
touch packages/skin-database/config.js
|
||||
yarn test
|
||||
yarn workspace webamp test
|
||||
- name: Run Integration Tests
|
||||
run: yarn workspace webamp integration-tests
|
||||
npx turbo build build-library
|
||||
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()
|
||||
NODE_ENV: production
|
||||
- name: Lint and type-check
|
||||
run: |
|
||||
yarn workspace webamp integration-tests -u
|
||||
- name: Upload New Screenshots
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
npx turbo lint type-check
|
||||
- name: Validate Grats generated files are up-to-date
|
||||
run: ./scripts/validate-grats.sh
|
||||
- name: Run tests
|
||||
run: |
|
||||
npx turbo test -- --maxWorkers=2
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- name: Cache build artifacts for release
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
name: new_images
|
||||
path: packages/webamp/js/__tests__/__image_snapshots__/
|
||||
main-release:
|
||||
name: Publish to NPM
|
||||
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: "yarn"
|
||||
cache: "pnpm"
|
||||
- name: Update npm to latest version
|
||||
run: npm install -g npm@latest
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
- name: Build latest (main) version
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Restore build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/ani-cursor/dist
|
||||
packages/winamp-eqf/built
|
||||
packages/webamp/built
|
||||
key: release-artifacts-${{ github.sha }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Set version for all packages
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
yarn workspace webamp build-library
|
||||
echo "Setting version to 0.0.0-next-${RELEASE_COMMIT_SHA::7}"
|
||||
cd packages/webamp && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
cd ../ani-cursor && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
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
|
||||
if: github.ref == 'refs/heads/main' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
run: |
|
||||
npm publish webamp ${TAG}
|
||||
env:
|
||||
TAG: ${{ github.ref == 'refs/heads/main' && '--tag=main' || ((contains(github.ref_name, '-rc.') && '--tag=dev') || '' )}}
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
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"
|
||||
82
README.md
82
README.md
|
|
@ -1,45 +1,31 @@
|
|||
[](https://bundlephobia.com/result?p=webamp)
|
||||
[](https://github.com/captbaritone/webamp/actions?query=branch%3Amaster+workflow%3ACI)
|
||||
[](https://webamp.org/chat)
|
||||
|
||||
# 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
|
||||
|
||||
Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in addition to the Webamp NPM module, this repository contains code for a few closely related projects and some pieces of Webamp which are published as standalone modules:
|
||||
|
||||
* [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
|
||||
* [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
|
||||
* [`packages/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
|
||||
- [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
|
||||
- [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
|
||||
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
|
||||
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
|
||||
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
|
||||
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
|
||||
- [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
|
||||
- [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module
|
||||
|
||||
## Community
|
||||
|
||||
|
|
@ -79,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,15 +2,15 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh">
|
||||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
|
||||
<script>
|
||||
const Webamp = window.Webamp;
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://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,
|
||||
},
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
],
|
||||
});
|
||||
|
||||
// Returns a promise indicating when it's done loading.
|
||||
webamp.renderWhenReady(document.getElementById("app"));
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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,17 +2,20 @@
|
|||
<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 src="https://unpkg.com/webamp@1.5.0/built/webamp.bundle.min.js"></script>
|
||||
<script src="https://unpkg.com/butterchurn@2.6.7/lib/butterchurn.min.js"></script>
|
||||
<script src="https://unpkg.com/butterchurn-presets@2.4.7/lib/butterchurnPresets.min.js"></script>
|
||||
<script>
|
||||
const Webamp = window.Webamp;
|
||||
<script type="module">
|
||||
/**
|
||||
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
|
||||
* entrypoint which includes the Butterchurn library to enable the
|
||||
* Milkdrop visualizer.
|
||||
*/
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
@ -22,36 +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(window.butterchurn),
|
||||
getPresets: () => {
|
||||
const presets = window.butterchurnPresets.getPresets();
|
||||
return Object.keys(presets).map((name) => {
|
||||
return {
|
||||
name,
|
||||
butterchurnPresetObject: presets[name],
|
||||
};
|
||||
});
|
||||
},
|
||||
butterchurnOpen: true,
|
||||
},
|
||||
windowLayout: {
|
||||
main: { position: { top: 0, left: 0 } },
|
||||
equalizer: { position: { top: 116, left: 0 } },
|
||||
playlist: {
|
||||
position: { top: 232, left: 0 },
|
||||
size: { extraWidth: 0, extraHeight: 4 },
|
||||
},
|
||||
milkdrop: {
|
||||
position: { top: 0, left: 275 },
|
||||
size: { extraHeight: 12, extraWidth: 7 },
|
||||
},
|
||||
},
|
||||
});
|
||||
webamp.renderWhenReady(document.getElementById("app"));
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh">
|
||||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script src="https://unpkg.com/webamp@0.0.0-next-6d0ec37b/built/webamp.bundle.min.js"></script>
|
||||
<script>
|
||||
const Webamp = window.Webamp;
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@^2";
|
||||
const webamp = new Webamp({
|
||||
windowLayout: {
|
||||
main: {
|
||||
|
|
|
|||
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,22 +2,27 @@
|
|||
<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 src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
|
||||
<script>
|
||||
const Webamp = window.Webamp;
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@^2";
|
||||
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",
|
||||
|
|
@ -27,6 +32,9 @@
|
|||
name: "Mac OSX v1.5 (Aqua)",
|
||||
},
|
||||
],
|
||||
initialSkin: {
|
||||
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
|
||||
},
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh">
|
||||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
|
||||
<script>
|
||||
const Webamp = window.Webamp;
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@^2";
|
||||
const webamp = new Webamp({
|
||||
/**
|
||||
* Here we list three tracks. Note that the `metaData` fields and
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://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,
|
||||
},
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://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,
|
||||
},
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://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,15 +0,0 @@
|
|||
# Minimal Example
|
||||
|
||||
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time.
|
||||
|
||||
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
|
||||
|
||||
To try it out:
|
||||
|
||||
```
|
||||
$ git clone git@github.com:captbaritone/webamp.git
|
||||
$ cd webamp/examples/webpack/
|
||||
$ npm install
|
||||
$ npm run build
|
||||
$ open index.html
|
||||
```
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh"></div>
|
||||
<script src="./bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import Webamp from "webamp";
|
||||
|
||||
new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
}).renderWhenReady(document.getElementById("app"));
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "webamp-webpack-example",
|
||||
"version": "0.0.0",
|
||||
"description": "An example of using Webamp within a Webpack bundle",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack index.js -o bundle.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"webamp": "1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^4.29.6",
|
||||
"webpack-cli": "^3.3.0"
|
||||
},
|
||||
"prettier": {}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Minimal Lazy Loading Example
|
||||
|
||||
## API is still being finalized and may change when released
|
||||
|
||||
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time. Milkdrop and visualizer presets are lazy loaded after playing starts.
|
||||
|
||||
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
|
||||
|
||||
To try it out:
|
||||
|
||||
```
|
||||
$ git clone git@github.com:captbaritone/webamp.git
|
||||
$ cd webamp/examples/webpackLazyLoad/
|
||||
$ npm install
|
||||
$ npm run build
|
||||
$ open index.html
|
||||
```
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh"></div>
|
||||
<script src="./bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import Webamp from "webamp";
|
||||
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
__butterchurnOptions: {
|
||||
importButterchurn: () => {
|
||||
// Only load butterchurn when music starts playing to reduce initial page load
|
||||
return import("butterchurn");
|
||||
},
|
||||
getPresets: async () => {
|
||||
// Load presets from preset URL mapping on demand as they are used
|
||||
const resp = await fetch(
|
||||
// NOTE: Your preset file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
"https://unpkg.com/butterchurn-presets-weekly@0.0.2/weeks/week1/presets.json"
|
||||
);
|
||||
const namesToPresetUrls = await resp.json();
|
||||
return Object.keys(namesToPresetUrls).map((name) => {
|
||||
return { name, butterchurnPresetUrl: namesToPresetUrls[name] };
|
||||
});
|
||||
},
|
||||
butterchurnOpen: true,
|
||||
},
|
||||
__initialWindowLayout: {
|
||||
main: { position: { x: 0, y: 0 } },
|
||||
equalizer: { position: { x: 0, y: 116 } },
|
||||
playlist: { position: { x: 0, y: 232 }, size: [0, 4] },
|
||||
milkdrop: { position: { x: 275, y: 0 }, size: [7, 12] },
|
||||
},
|
||||
});
|
||||
|
||||
webamp.renderWhenReady(document.getElementById("app"));
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "webamp-webpack-lazy-example",
|
||||
"version": "0.0.0",
|
||||
"description": "An example of using Webamp within a Webpack bundle",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack index.js -o bundle.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"butterchurn": "^2.6.7",
|
||||
"webamp": "1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^4.29.6",
|
||||
"webpack-cli": "^3.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
61959
package-lock.json
generated
61959
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/**"],
|
||||
};
|
||||
|
|
|
|||
3
packages/skin-database/.gitignore
vendored
3
packages/skin-database/.gitignore
vendored
|
|
@ -7,4 +7,5 @@ dist/
|
|||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
.env
|
||||
.env
|
||||
.next
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ The discord bot allows us to:
|
|||
## Server
|
||||
|
||||
This package also includes a GraphQL interface for exploring skins. It is not currently used by anything, but can be useful for inspecting the data.
|
||||
sudo systemctl reload apache2
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
@ -117,6 +117,7 @@ async function addClassicSkinFromBuffer(
|
|||
|
||||
await setHashesForSkin(skin);
|
||||
|
||||
// Disable while we figure out our quota
|
||||
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,4 +1,4 @@
|
|||
import { ApiAction } from "./app";
|
||||
import { ApiAction } from "./types";
|
||||
import Discord, { TextChannel } from "discord.js";
|
||||
import * as Config from "../config";
|
||||
import SkinModel from "../data/SkinModel";
|
||||
|
|
@ -105,7 +105,7 @@ export default class DiscordEventHandler {
|
|||
case "SYNCED_TO_ARCHIVE": {
|
||||
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
|
||||
|
||||
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()}.`;
|
||||
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()} Skipped: ${action.skips.toLocaleString()}.`;
|
||||
|
||||
await dest.send(message);
|
||||
break;
|
||||
|
|
@ -172,7 +172,8 @@ export default class DiscordEventHandler {
|
|||
dest,
|
||||
});
|
||||
} else {
|
||||
await DiscordUtils.sendAlreadyReviewed({ md5, dest });
|
||||
// Too much nosie
|
||||
// await DiscordUtils.sendAlreadyReviewed({ md5, dest });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
// 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": null,
|
||||
"filename": "a_fake_archive_file.bmp",
|
||||
"is_directory": false,
|
||||
"size": null,
|
||||
"skin": Object {
|
||||
"skin": {
|
||||
"md5": "a_fake_md5",
|
||||
},
|
||||
"text_content": null,
|
||||
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5/null",
|
||||
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5.wsz/a_fake_archive_file.bmp",
|
||||
},
|
||||
],
|
||||
"average_color": null,
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import { Application } from "express";
|
||||
import { knex } from "../../db";
|
||||
import request from "supertest"; // supertest is a framework that allows to easily test web apis
|
||||
import { createApp } from "../app";
|
||||
import SkinModel from "../../data/SkinModel";
|
||||
import * as S3 from "../../s3";
|
||||
import * as Auth from "../auth";
|
||||
import { processUserUploads } from "../processUserUploads";
|
||||
import UserContext from "../../data/UserContext";
|
||||
import { searchIndex } from "../../algolia";
|
||||
import { client } from "../../algolia";
|
||||
import { createYogaInstance } from "../../app/graphql/yoga";
|
||||
import { YogaServerInstance } from "graphql-yoga";
|
||||
jest.mock("../../s3");
|
||||
jest.mock("../../algolia");
|
||||
jest.mock("../processUserUploads");
|
||||
jest.mock("../auth");
|
||||
|
||||
let app: Application;
|
||||
let yoga: YogaServerInstance<any, any>;
|
||||
const handler = jest.fn();
|
||||
const log = jest.fn();
|
||||
const logError = jest.fn();
|
||||
|
|
@ -23,26 +21,30 @@ let username: string | undefined;
|
|||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
username = "<MOCKED>";
|
||||
app = createApp({
|
||||
yoga = createYogaInstance({
|
||||
eventHandler: handler,
|
||||
extraMiddleware: (req, res, next) => {
|
||||
req.session.username = username;
|
||||
next();
|
||||
},
|
||||
getUserContext: () => new UserContext(username),
|
||||
logger: { log, logError },
|
||||
});
|
||||
await knex.migrate.latest();
|
||||
await knex.seed.run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
function gql(templateString: TemplateStringsArray): string {
|
||||
return templateString[0];
|
||||
}
|
||||
|
||||
async function graphQLRequest(query: string, variables?: any) {
|
||||
const { body } = await request(app)
|
||||
.post("/graphql")
|
||||
.send({ query, variables: variables ?? {} });
|
||||
const response = await yoga.fetch("/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.errors && body.errors.length) {
|
||||
for (const err of body.errors) {
|
||||
console.warn(err.message);
|
||||
|
|
@ -106,37 +108,6 @@ describe(".me", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// TODO: The redirect_uri is different on github
|
||||
test("/auth", async () => {
|
||||
const { body } = await request(app).get("/auth").expect(302);
|
||||
// TODO: The redirect_uri is different on github
|
||||
// .expect(
|
||||
// "Location",
|
||||
// "https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
|
||||
// );
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
describe("/auth/discord", () => {
|
||||
test("valid code", async () => {
|
||||
const response = await request(app)
|
||||
.get("/auth/discord")
|
||||
.query({ code: "<A_FAKE_CODE>" })
|
||||
.expect(302);
|
||||
// TODO: The location is different on github
|
||||
// .expect("Location", "https://skins.webamp.org/review/");
|
||||
// TODO: Assert that we get cookie headers. I think that will not work now
|
||||
// because express does not think it's secure in a test env.
|
||||
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
|
||||
expect(response.body).toEqual({});
|
||||
});
|
||||
test("missing code", async () => {
|
||||
const { body } = await request(app).get("/auth/discord").expect(400);
|
||||
expect(Auth.auth).not.toHaveBeenCalled();
|
||||
expect(body).toEqual({ message: "Expected to get a code" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query.skins", () => {
|
||||
test("no query params", async () => {
|
||||
const { data } = await graphQLRequest(
|
||||
|
|
@ -156,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,
|
||||
|
|
@ -212,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,
|
||||
|
|
@ -360,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,381 +0,0 @@
|
|||
import { Application } from "express";
|
||||
import { knex } from "../../db";
|
||||
import request from "supertest"; // supertest is a framework that allows to easily test web apis
|
||||
import { createApp } from "../app";
|
||||
import SkinModel from "../../data/SkinModel";
|
||||
import * as S3 from "../../s3";
|
||||
import * as Auth from "../auth";
|
||||
import { processUserUploads } from "../processUserUploads";
|
||||
import UserContext from "../../data/UserContext";
|
||||
import { searchIndex } from "../../algolia";
|
||||
jest.mock("../../s3");
|
||||
jest.mock("../../algolia");
|
||||
jest.mock("../processUserUploads");
|
||||
jest.mock("../auth");
|
||||
|
||||
let app: Application;
|
||||
const handler = jest.fn();
|
||||
const log = jest.fn();
|
||||
const logError = jest.fn();
|
||||
|
||||
let username: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
username = "<MOCKED>";
|
||||
app = createApp({
|
||||
eventHandler: handler,
|
||||
extraMiddleware: (req, res, next) => {
|
||||
req.session.username = username;
|
||||
next();
|
||||
},
|
||||
logger: { log, logError },
|
||||
});
|
||||
await knex.migrate.latest();
|
||||
await knex.seed.run();
|
||||
});
|
||||
|
||||
describe("/authed", () => {
|
||||
test("logged in ", async () => {
|
||||
const { body } = await request(app).get("/authed").expect(200);
|
||||
expect(body).toEqual({ username: "<MOCKED>" });
|
||||
});
|
||||
test("not logged in", async () => {
|
||||
username = undefined;
|
||||
const { body } = await request(app).get("/authed").expect(200);
|
||||
expect(body).toEqual({ username: null });
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("/auth", async () => {
|
||||
const { body } = await request(app)
|
||||
.get("/auth")
|
||||
.expect(302)
|
||||
.expect(
|
||||
"Location",
|
||||
"https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
|
||||
);
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
describe.skip("/auth/discord", () => {
|
||||
test("valid code", async () => {
|
||||
const response = await request(app)
|
||||
.get("/auth/discord")
|
||||
.query({ code: "<A_FAKE_CODE>" })
|
||||
.expect(302)
|
||||
.expect("Location", "https://skins.webamp.org/review/");
|
||||
// TODO: Assert that we get cookie headers. I think that will not work now
|
||||
// because express does not think it's secure in a test env.
|
||||
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
|
||||
expect(response.body).toEqual({});
|
||||
});
|
||||
test("missing code", async () => {
|
||||
const { body } = await request(app).get("/auth/discord").expect(400);
|
||||
expect(Auth.auth).not.toHaveBeenCalled();
|
||||
expect(body).toEqual({ message: "Expected to get a code" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("/skins/", () => {
|
||||
test("no query params", async () => {
|
||||
const { body } = await request(app).get("/skins/");
|
||||
expect(body).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"skinCount": 6,
|
||||
"skins": Array [
|
||||
Object {
|
||||
"fileName": "tweeted.wsz",
|
||||
"md5": "a_tweeted_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "Zelda_Amp_3.wsz",
|
||||
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "path.wsz",
|
||||
"md5": "a_fake_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "approved.wsz",
|
||||
"md5": "an_approved_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "rejected.wsz",
|
||||
"md5": "a_rejected_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "nsfw.wsz",
|
||||
"md5": "a_nsfw_md5",
|
||||
"nsfw": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
test("first and offset", async () => {
|
||||
const { body } = await request(app)
|
||||
.get("/skins/")
|
||||
.query({ first: 2, offset: 1 });
|
||||
expect(body).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"skinCount": 6,
|
||||
"skins": Array [
|
||||
Object {
|
||||
"fileName": "Zelda_Amp_3.wsz",
|
||||
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
"fileName": "path.wsz",
|
||||
"md5": "a_fake_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
// This is deprecated and fails in CI due to printing as localize date string.
|
||||
test.skip("/skins/a_fake_md5/debug", async () => {
|
||||
const { body } = await request(app)
|
||||
.get("/skins/a_fake_md5/debug")
|
||||
.expect(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("/skins/a_fake_md5/report", async () => {
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_fake_md5/report")
|
||||
.expect(200);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
type: "REVIEW_REQUESTED",
|
||||
md5: "a_fake_md5",
|
||||
});
|
||||
expect(body).toEqual({}); // TODO: Where does the text response go?
|
||||
});
|
||||
|
||||
test("/skins/a_fake_md5/approve", async () => {
|
||||
const ctx = new UserContext();
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_fake_md5/approve")
|
||||
.expect(200);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
type: "APPROVED_SKIN",
|
||||
md5: "a_fake_md5",
|
||||
});
|
||||
expect(body).toEqual({ message: "The skin has been approved." });
|
||||
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
|
||||
|
||||
expect(await skin?.getTweetStatus()).toEqual("APPROVED");
|
||||
});
|
||||
|
||||
describe("/to_review", () => {
|
||||
test("logged in ", async () => {
|
||||
const { body } = await request(app).get("/to_review").expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
filename: expect.any(String),
|
||||
md5: expect.any(String),
|
||||
});
|
||||
});
|
||||
test("not logged in ", async () => {
|
||||
username = undefined;
|
||||
const { body } = await request(app).get("/to_review").expect(403);
|
||||
expect(body).toEqual({ message: "You must be logged in" });
|
||||
});
|
||||
});
|
||||
|
||||
test("/skins/a_md5_that_does_not_exist/approve (404)", async () => {
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_md5_that_does_not_exist/approve")
|
||||
.expect(404);
|
||||
|
||||
expect(body).toEqual({});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("/skins/a_fake_md5/reject", async () => {
|
||||
const ctx = new UserContext();
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_fake_md5/reject")
|
||||
.expect(200);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
type: "REJECTED_SKIN",
|
||||
md5: "a_fake_md5",
|
||||
});
|
||||
expect(body).toEqual({ message: "The skin has been rejected." }); // TODO: Where does the text response go?
|
||||
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
|
||||
|
||||
expect(await skin?.getTweetStatus()).toEqual("REJECTED");
|
||||
});
|
||||
|
||||
test("/skins/a_md5_that_does_not_exist/reject (404)", async () => {
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_md5_that_does_not_exist/reject")
|
||||
.expect(404);
|
||||
|
||||
expect(body).toEqual({});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("/skins/a_fake_md5/nsfw", async () => {
|
||||
const ctx = new UserContext();
|
||||
const { body } = await request(app)
|
||||
.post("/skins/a_fake_md5/nsfw")
|
||||
.expect(200);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
type: "MARKED_SKIN_NSFW",
|
||||
md5: "a_fake_md5",
|
||||
});
|
||||
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
|
||||
{ nsfw: true, objectID: "a_fake_md5" },
|
||||
]);
|
||||
expect(body).toEqual({ message: "The skin has been marked as NSFW." });
|
||||
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
|
||||
|
||||
expect(await skin?.getTweetStatus()).toEqual("NSFW");
|
||||
});
|
||||
|
||||
// TODO: Actually upload some skins?
|
||||
test("/skins/status", async () => {
|
||||
const { body } = await request(app)
|
||||
.post("/skins/status")
|
||||
.send({ hashes: ["a_fake_md5", "a_missing_md5"] });
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
test("/approved", async () => {
|
||||
const { body } = await request(app).get("/approved").expect(200);
|
||||
expect(body).toEqual(["an_approved_md5", "a_tweeted_md5"]);
|
||||
});
|
||||
|
||||
test("/skins/a_fake_md5", async () => {
|
||||
let response = await request(app).get("/skins/a_fake_md5");
|
||||
expect(response.body).toEqual({
|
||||
fileName: "path.wsz",
|
||||
md5: "a_fake_md5",
|
||||
nsfw: false,
|
||||
});
|
||||
response = await request(app).get("/skins/a_nsfw_md5");
|
||||
expect(response.body).toEqual({
|
||||
fileName: "nsfw.wsz",
|
||||
md5: "a_nsfw_md5",
|
||||
nsfw: true,
|
||||
});
|
||||
await request(app).get("/skins/does_not_exist_md5").expect(404);
|
||||
});
|
||||
|
||||
test("/skins/get_upload_urls", async () => {
|
||||
const { body } = await request(app)
|
||||
.post("/skins/get_upload_urls")
|
||||
.send({
|
||||
skins: {
|
||||
"3b73bcd43c30b85d4cad3083e8ac9695": "a_fake_new_file.wsz",
|
||||
"48bbdbbeb03d347e59b1eebda4d352d0":
|
||||
"a_new_name_for_a_file_that_exists.wsz",
|
||||
},
|
||||
});
|
||||
|
||||
expect(S3.getSkinUploadUrl).toHaveBeenCalledWith(
|
||||
"3b73bcd43c30b85d4cad3083e8ac9695",
|
||||
expect.any(Number)
|
||||
);
|
||||
|
||||
expect(body).toEqual({
|
||||
"3b73bcd43c30b85d4cad3083e8ac9695": {
|
||||
id: expect.any(Number),
|
||||
url: "<MOCK_S3_UPLOAD_URL>",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("An Upload Flow", async () => {
|
||||
// Request an upload URL
|
||||
const md5 = "3b73bcd43c30b85d4cad3083e8ac9695";
|
||||
const filename = "a_fake_new_file.wsz";
|
||||
const skins = { [md5]: filename };
|
||||
const getUrlsResponse = await request(app)
|
||||
.post("/skins/get_upload_urls")
|
||||
.send({ skins });
|
||||
|
||||
const id = getUrlsResponse.body[md5].id;
|
||||
|
||||
expect(getUrlsResponse.body).toEqual({
|
||||
[md5]: { id: expect.any(Number), url: "<MOCK_S3_UPLOAD_URL>" },
|
||||
});
|
||||
|
||||
const requestedUpload = await knex("skin_uploads").where({ id }).first();
|
||||
expect(requestedUpload).toEqual({
|
||||
filename,
|
||||
id,
|
||||
skin_md5: md5,
|
||||
status: "URL_REQUESTED",
|
||||
});
|
||||
|
||||
// Report that we've uploaded the skin to S3 (we lie)
|
||||
const uploadedResponse = await request(app)
|
||||
.post(`/skins/${md5}/uploaded`)
|
||||
.query({ id })
|
||||
.send({ skins });
|
||||
expect(uploadedResponse.body).toEqual({ done: true });
|
||||
expect(processUserUploads).toHaveBeenCalled();
|
||||
|
||||
const reportedUpload = await knex("skin_uploads").where({ id }).first();
|
||||
expect(reportedUpload).toEqual({
|
||||
filename,
|
||||
id,
|
||||
skin_md5: md5,
|
||||
status: "UPLOAD_REPORTED",
|
||||
});
|
||||
});
|
||||
|
||||
test("/stylegan.json", async () => {
|
||||
const response = await request(app).get("/stylegan.json");
|
||||
expect(response.body).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"fileName": "Zelda_Amp_3.wsz",
|
||||
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
|
||||
"nsfw": false,
|
||||
"url": "https://r2.webampskins.org/screenshots/48bbdbbeb03d347e59b1eebda4d352d0.png",
|
||||
},
|
||||
Object {
|
||||
"fileName": "path.wsz",
|
||||
"md5": "a_fake_md5",
|
||||
"nsfw": false,
|
||||
"url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
|
||||
},
|
||||
Object {
|
||||
"fileName": "nsfw.wsz",
|
||||
"md5": "a_nsfw_md5",
|
||||
"nsfw": true,
|
||||
"url": "https://r2.webampskins.org/screenshots/a_nsfw_md5.png",
|
||||
},
|
||||
Object {
|
||||
"fileName": "rejected.wsz",
|
||||
"md5": "a_rejected_md5",
|
||||
"nsfw": false,
|
||||
"url": "https://r2.webampskins.org/screenshots/a_rejected_md5.png",
|
||||
},
|
||||
Object {
|
||||
"fileName": "tweeted.wsz",
|
||||
"md5": "a_tweeted_md5",
|
||||
"nsfw": false,
|
||||
"url": "https://r2.webampskins.org/screenshots/a_tweeted_md5.png",
|
||||
},
|
||||
Object {
|
||||
"fileName": "approved.wsz",
|
||||
"md5": "an_approved_md5",
|
||||
"nsfw": false,
|
||||
"url": "https://r2.webampskins.org/screenshots/an_approved_md5.png",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import router from "./router";
|
||||
import graphql from "./graphql";
|
||||
import fileUpload from "express-fileupload";
|
||||
import cors, { CorsOptions } from "cors";
|
||||
import bodyParser from "body-parser";
|
||||
import Sentry from "@sentry/node";
|
||||
import expressSitemapXml from "express-sitemap-xml";
|
||||
import * as Skins from "../data/skins";
|
||||
import express, { Handler, RequestHandler, ErrorRequestHandler } from "express";
|
||||
import UserContext from "../data/UserContext";
|
||||
import cookieSession from "cookie-session";
|
||||
import { SECRET } from "../config";
|
||||
|
||||
export type ApiAction =
|
||||
| { type: "REVIEW_REQUESTED"; md5: string }
|
||||
| { type: "REJECTED_SKIN"; md5: string }
|
||||
| { type: "APPROVED_SKIN"; md5: string }
|
||||
| { type: "MARKED_SKIN_NSFW"; md5: string }
|
||||
| { type: "SKIN_UPLOADED"; md5: string }
|
||||
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
|
||||
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
|
||||
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
|
||||
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
|
||||
| {
|
||||
type: "GOT_FEEDBACK";
|
||||
message: string;
|
||||
email?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
| { type: "SYNCED_TO_ARCHIVE"; successes: number; errors: number }
|
||||
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
|
||||
| {
|
||||
type: "POPULAR_TWEET";
|
||||
bracket: number;
|
||||
url: string;
|
||||
likes: number;
|
||||
date: Date;
|
||||
}
|
||||
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
|
||||
|
||||
export type EventHandler = (event: ApiAction) => void;
|
||||
export type Logger = {
|
||||
log(message: string, context: any): void;
|
||||
logError(message: string, context: any): void;
|
||||
};
|
||||
|
||||
// Add UserContext to req objects globally
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
ctx: UserContext;
|
||||
notify(action: ApiAction): void;
|
||||
log(message: string): void;
|
||||
logError(message: string): void;
|
||||
startTime: number;
|
||||
session: {
|
||||
username: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Options = {
|
||||
eventHandler?: EventHandler;
|
||||
extraMiddleware?: Handler;
|
||||
logger?: Logger;
|
||||
};
|
||||
|
||||
export function createApp({ eventHandler, extraMiddleware, logger }: Options) {
|
||||
const app = express();
|
||||
if (Sentry) {
|
||||
app.use(Sentry.Handlers.requestHandler() as RequestHandler);
|
||||
}
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
req.startTime = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
// https://expressjs.com/en/guide/behind-proxies.html
|
||||
// This is needed in order to allow `cookieSession({secure: true})` cookies to be sent.
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
function use(handler: RequestHandler) {
|
||||
app.use(handler);
|
||||
}
|
||||
|
||||
const cookieHandler: RequestHandler = cookieSession({
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
httpOnly: false,
|
||||
name: "session",
|
||||
secret: SECRET,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
// @ts-ignore Tests fail if this is missing, but prod is fine.
|
||||
keys: "what",
|
||||
});
|
||||
|
||||
app.use(cookieHandler);
|
||||
|
||||
if (extraMiddleware != null) {
|
||||
app.use(extraMiddleware);
|
||||
}
|
||||
|
||||
// Add UserContext to request
|
||||
app.use((req, res, next) => {
|
||||
req.ctx = new UserContext(req.session.username);
|
||||
next();
|
||||
// TODO: Dispose of context?
|
||||
});
|
||||
|
||||
// Attach event handler
|
||||
app.use((req, res, next) => {
|
||||
req.notify = (action) => {
|
||||
if (eventHandler) {
|
||||
eventHandler(action);
|
||||
}
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
// Attach logger
|
||||
app.use((req, res, next) => {
|
||||
const context = {
|
||||
url: req.url,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
username: req.ctx.username,
|
||||
};
|
||||
req.log = (message) => {
|
||||
if (logger != null) {
|
||||
logger.log(message, context);
|
||||
}
|
||||
};
|
||||
req.logError = (message) => {
|
||||
if (logger != null) {
|
||||
logger.logError(message, context);
|
||||
}
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure CORs
|
||||
app.use(cors(corsOptions));
|
||||
app.options("*", cors(corsOptions));
|
||||
|
||||
// Configure json output
|
||||
app.set("json spaces", 2);
|
||||
|
||||
// parse application/json
|
||||
app.use(bodyParser.json() as RequestHandler);
|
||||
|
||||
// Configure File Uploads
|
||||
const limits = { fileSize: 50 * 1024 * 1024 };
|
||||
app.use(fileUpload({ limits }));
|
||||
|
||||
// Configure sitemap
|
||||
app.use(expressSitemapXml(getSitemapUrls, "https://skins.webamp.org"));
|
||||
|
||||
// Add routes
|
||||
app.use("/", router);
|
||||
app.use("/graphql", graphql);
|
||||
|
||||
// The error handler must be before any other error middleware and after all controllers
|
||||
if (Sentry) {
|
||||
app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler);
|
||||
}
|
||||
|
||||
// Optional fallthrough error handler
|
||||
app.use(function onError(err, _req, res, _next) {
|
||||
console.error(err);
|
||||
res.statusCode = 500;
|
||||
res.json({ errorId: res.sentry, message: err.message });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getSitemapUrls() {
|
||||
const md5s = await Skins.getAllClassicSkins();
|
||||
const skinUrls = md5s.map(({ md5, fileName }) => `skin/${md5}/${fileName}`);
|
||||
return ["/about", "/", "/upload", ...skinUrls];
|
||||
}
|
||||
|
||||
const allowList = [
|
||||
/https:\/\/skins\.webamp\.org/,
|
||||
/https:\/\/api\.webamp\.org/,
|
||||
/https:\/\/webamp\.org/,
|
||||
/https:\/\/[^.]*\.csb\.app/,
|
||||
/https:\/\/winamp-skin-museum\.pages\.dev/,
|
||||
/http:\/\/localhost:3000/,
|
||||
/http:\/\/localhost:3001/,
|
||||
/netlify.app/,
|
||||
/https:\/\/dustinbrett.com/,
|
||||
];
|
||||
|
||||
const corsOptions: CorsOptions = {
|
||||
credentials: true,
|
||||
origin: function (origin, callback) {
|
||||
if (!origin || allowList.some((regex) => regex.test(origin))) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(
|
||||
new Error(`Request from origin "${origin}" not allowed by CORS.`)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -2,8 +2,7 @@ import { Int } from "grats";
|
|||
import SkinModel from "../../data/SkinModel";
|
||||
import { knex } from "../../db";
|
||||
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
|
||||
import { Ctx } from ".";
|
||||
import { Query } from "./resolvers/QueryResolver";
|
||||
import UserContext from "../../data/UserContext.js";
|
||||
|
||||
/**
|
||||
* A collection of "modern" Winamp skins
|
||||
|
|
@ -31,7 +30,7 @@ export default class ModernSkinsConnection {
|
|||
/**
|
||||
* The list of skins
|
||||
* @gqlField */
|
||||
async nodes({ ctx }: Ctx): Promise<Array<ModernSkinResolver | null>> {
|
||||
async nodes(ctx: UserContext): Promise<Array<ModernSkinResolver | null>> {
|
||||
const skins = await this._getQuery()
|
||||
.select()
|
||||
.limit(this._first)
|
||||
|
|
@ -44,16 +43,10 @@ export default class ModernSkinsConnection {
|
|||
|
||||
/**
|
||||
* All modern skins in the database
|
||||
* @gqlField */
|
||||
* @gqlQueryField */
|
||||
export async function modern_skins(
|
||||
_: Query,
|
||||
{
|
||||
first = 10,
|
||||
offset = 0,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
}
|
||||
first: Int = 10,
|
||||
offset: Int = 0
|
||||
): Promise<ModernSkinsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import SkinResolver from "./resolvers/SkinResolver";
|
|||
import LRU from "lru-cache";
|
||||
import { Int } from "grats";
|
||||
import { ISkin } from "./resolvers/CommonSkinResolver";
|
||||
import { Ctx } from ".";
|
||||
import { Query } from "./resolvers/QueryResolver";
|
||||
import UserContext from "../../data/UserContext.js";
|
||||
|
||||
const options = {
|
||||
max: 100,
|
||||
|
|
@ -103,7 +102,7 @@ export default class SkinsConnection {
|
|||
* The list of skins
|
||||
* @gqlField
|
||||
*/
|
||||
async nodes({ ctx }: Ctx): Promise<Array<ISkin | null>> {
|
||||
async nodes(ctx: UserContext): Promise<Array<ISkin | null>> {
|
||||
if (this._sort === "MUSEUM") {
|
||||
if (this._filter) {
|
||||
throw new Error(
|
||||
|
|
@ -171,21 +170,18 @@ Only the skins that have been tweeted
|
|||
* All classic skins in the database
|
||||
*
|
||||
* **Note:** We don't currently support combining sorting and filtering.
|
||||
* @gqlField */
|
||||
export function skins(
|
||||
_: Query,
|
||||
{
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
filter,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: SkinsSortOption | null;
|
||||
filter?: SkinsFilterOption | null;
|
||||
}
|
||||
): SkinsConnection {
|
||||
* @gqlQueryField */
|
||||
export function skins({
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
filter,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: SkinsSortOption | null;
|
||||
filter?: SkinsFilterOption | null;
|
||||
}): SkinsConnection {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Int } from "grats";
|
||||
import TweetModel from "../../data/TweetModel";
|
||||
import { knex } from "../../db";
|
||||
import { Query } from "./resolvers/QueryResolver";
|
||||
|
||||
/** @gqlEnum */
|
||||
export type TweetsSortOption = "LIKES" | "RETWEETS";
|
||||
|
|
@ -51,20 +50,17 @@ export default class TweetsConnection {
|
|||
|
||||
/**
|
||||
* Tweets tweeted by @winampskins
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export async function tweets(
|
||||
_: Query,
|
||||
{
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: TweetsSortOption | null;
|
||||
}
|
||||
): Promise<TweetsConnection> {
|
||||
export async function tweets({
|
||||
first = 10,
|
||||
offset = 0,
|
||||
sort,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
sort?: TweetsSortOption | null;
|
||||
}): Promise<TweetsConnection> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Maximum limit is 1000");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,9 @@
|
|||
import { Router } from "express";
|
||||
import { createHandler } from "graphql-http/lib/use/express";
|
||||
|
||||
// import DEFAULT_QUERY from "./defaultQuery";
|
||||
import { getSchema } from "./schema";
|
||||
import UserContext from "../../data/UserContext.js";
|
||||
|
||||
/** @gqlContext */
|
||||
export type Ctx = Express.Request;
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(
|
||||
"/",
|
||||
createHandler<Ctx>({
|
||||
schema: getSchema(),
|
||||
context: (req) => {
|
||||
return req.raw;
|
||||
},
|
||||
/*
|
||||
graphiql: {
|
||||
defaultQuery: DEFAULT_QUERY,
|
||||
},*/
|
||||
// graphqlHTTP({
|
||||
// schema: getSchema(),
|
||||
// graphiql: {
|
||||
// defaultQuery: DEFAULT_QUERY,
|
||||
// },
|
||||
// customFormatErrorFn: (error) => {
|
||||
// console.error(error);
|
||||
// return {
|
||||
// message: error.message,
|
||||
// locations: error.locations,
|
||||
// stack: error.stack ? error.stack.split("\n") : [],
|
||||
// path: error.path,
|
||||
// };
|
||||
// },
|
||||
// extensions,
|
||||
// }) as RequestHandler
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
/** @gqlContext */
|
||||
export function getUserContext(ctx: Ctx): UserContext {
|
||||
return ctx.ctx;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +1,5 @@
|
|||
import { Int } from "grats";
|
||||
import * as Skins from "../../../data/skins";
|
||||
import { Query } from "./QueryResolver";
|
||||
|
||||
/**
|
||||
* Statistics about the contents of the Museum's database.
|
||||
|
|
@ -92,7 +91,7 @@ export default class DatabaseStatisticsResolver {
|
|||
|
||||
/**
|
||||
* A namespace for statistics about the database
|
||||
* @gqlField */
|
||||
export function statistics(_: Query): DatabaseStatisticsResolver {
|
||||
* @gqlQueryField */
|
||||
export function statistics(): DatabaseStatisticsResolver {
|
||||
return new DatabaseStatisticsResolver();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { Ctx } from "..";
|
||||
import { Mutation } from "./MutationResolver";
|
||||
|
||||
/**
|
||||
* Send a message to the admin of the site. Currently this appears in Discord.
|
||||
* @gqlField */
|
||||
* @gqlMutationField */
|
||||
export async function send_feedback(
|
||||
_: Mutation,
|
||||
{
|
||||
message,
|
||||
email,
|
||||
url,
|
||||
}: { message: string; email?: string | null; url?: string | null },
|
||||
req: Ctx
|
||||
req: Ctx,
|
||||
message: string,
|
||||
email?: string | null,
|
||||
url?: string | null
|
||||
): Promise<boolean> {
|
||||
req.notify({
|
||||
type: "GOT_FEEDBACK",
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** @gqlType Mutation */
|
||||
export type Mutation = unknown;
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { ID } from "grats";
|
||||
import { Query } from "./QueryResolver";
|
||||
import { Ctx } from "..";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
|
||||
/**
|
||||
* A globally unique object. The `id` here is intended only for use within
|
||||
|
|
@ -34,12 +33,11 @@ export function fromId(base64Id: string): { graphqlType: string; id: string } {
|
|||
* Get a globally unique object by its ID.
|
||||
*
|
||||
* https://graphql.org/learn/global-object-identification/
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export async function node(
|
||||
_: Query,
|
||||
{ id }: { id: ID },
|
||||
{ ctx }: Ctx
|
||||
id: ID,
|
||||
ctx: UserContext
|
||||
): Promise<NodeResolver | null> {
|
||||
const { graphqlType, id: localId } = fromId(id);
|
||||
// TODO Use typeResolver
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** @gqlType Query */
|
||||
export type Query = unknown;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import SkinModel from "../../../data/SkinModel";
|
||||
import * as Skins from "../../../data/skins";
|
||||
import { Ctx } from "..";
|
||||
import { Mutation } from "./MutationResolver";
|
||||
|
||||
function requireAuthed(handler) {
|
||||
return (args, req: Ctx) => {
|
||||
|
|
@ -17,12 +16,8 @@ function requireAuthed(handler) {
|
|||
* Reject skin for tweeting
|
||||
*
|
||||
* **Note:** Requires being logged in
|
||||
* @gqlField */
|
||||
export function reject_skin(
|
||||
_: Mutation,
|
||||
md5: string,
|
||||
req: Ctx
|
||||
): Promise<boolean> {
|
||||
* @gqlMutationField */
|
||||
export function reject_skin(md5: string, req: Ctx): Promise<boolean> {
|
||||
return _reject_skin(md5, req);
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +36,8 @@ const _reject_skin = requireAuthed(async (md5: string, req: Ctx) => {
|
|||
* Approve skin for tweeting
|
||||
*
|
||||
* **Note:** Requires being logged in
|
||||
* @gqlField */
|
||||
export function approve_skin(
|
||||
_: Mutation,
|
||||
md5: string,
|
||||
req: Ctx
|
||||
): Promise<boolean> {
|
||||
* @gqlMutationField */
|
||||
export function approve_skin(md5: string, req: Ctx): Promise<boolean> {
|
||||
return _approve_skin(md5, req);
|
||||
}
|
||||
|
||||
|
|
@ -65,12 +56,8 @@ const _approve_skin = requireAuthed(async (md5: string, req: Ctx) => {
|
|||
* Mark a skin as NSFW
|
||||
*
|
||||
* **Note:** Requires being logged in
|
||||
* @gqlField */
|
||||
export function mark_skin_nsfw(
|
||||
_: Mutation,
|
||||
md5: string,
|
||||
req: Ctx
|
||||
): Promise<boolean> {
|
||||
* @gqlMutationField */
|
||||
export function mark_skin_nsfw(md5: string, req: Ctx): Promise<boolean> {
|
||||
return _mark_skin_nsfw(md5, req);
|
||||
}
|
||||
|
||||
|
|
@ -89,10 +76,9 @@ const _mark_skin_nsfw = requireAuthed(async (md5: string, req: Ctx) => {
|
|||
* Request that an admin check if this skin is NSFW.
|
||||
* Unlike other review mutation endpoints, this one does not require being logged
|
||||
* in.
|
||||
* @gqlField */
|
||||
* @gqlMutationField */
|
||||
export async function request_nsfw_review_for_skin(
|
||||
_: Mutation,
|
||||
{ md5 }: { md5: string },
|
||||
md5: string,
|
||||
req: Ctx
|
||||
): Promise<boolean> {
|
||||
req.log(`Reporting skin with hash "${md5}"`);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Ctx } from "..";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
import { Rating, ReviewRow } from "../../../types";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
|
@ -17,7 +17,7 @@ export default class ReviewResolver {
|
|||
* The skin that was reviewed
|
||||
* @gqlField
|
||||
*/
|
||||
skin({ ctx }: Ctx): Promise<ISkin | null> {
|
||||
skin(ctx: UserContext): Promise<ISkin | null> {
|
||||
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { Int } from "grats";
|
||||
import { Ctx } from "..";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import ClassicSkinResolver from "./ClassicSkinResolver";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import { Query } from "./QueryResolver";
|
||||
import algoliasearch from "algoliasearch";
|
||||
import * as Skins from "../../../data/skins";
|
||||
|
||||
// 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");
|
||||
import { knex } from "../../../db";
|
||||
|
||||
export default class SkinResolver {
|
||||
constructor() {
|
||||
|
|
@ -35,12 +29,11 @@ export default class SkinResolver {
|
|||
|
||||
/**
|
||||
* Get a skin by its MD5 hash
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export async function fetch_skin_by_md5(
|
||||
_: Query,
|
||||
{ md5 }: { md5: string },
|
||||
{ ctx }: Ctx
|
||||
md5: string,
|
||||
ctx: UserContext
|
||||
): Promise<ISkin | null> {
|
||||
const skin = await SkinModel.fromMd5(ctx, md5);
|
||||
if (skin == null) {
|
||||
|
|
@ -53,41 +46,79 @@ export async function fetch_skin_by_md5(
|
|||
* Search the database using the Algolia search index used by the Museum.
|
||||
*
|
||||
* Useful for locating a particular skin.
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export async function search_skins(
|
||||
_: Query,
|
||||
{
|
||||
query,
|
||||
first = 10,
|
||||
offset = 0,
|
||||
}: { query: string; first?: Int; offset?: Int },
|
||||
{ ctx }: Ctx
|
||||
query: string,
|
||||
first: Int = 10,
|
||||
offset: Int = 0,
|
||||
ctx: UserContext
|
||||
): Promise<Array<ISkin | null>> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Can only query 1000 records via search.");
|
||||
}
|
||||
|
||||
const results: { hits: { md5: string }[] } = await index.search(query, {
|
||||
attributesToRetrieve: ["md5"],
|
||||
length: first,
|
||||
offset,
|
||||
});
|
||||
const skins = await knex("skin_search")
|
||||
.select("skin_md5")
|
||||
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
|
||||
.where("skins.skin_type", "in", [1, 2])
|
||||
.limit(first)
|
||||
.offset(offset)
|
||||
.whereRaw("skin_search MATCH ?", query);
|
||||
|
||||
return Promise.all(
|
||||
results.hits.map(async (hit) => {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
|
||||
skins.map(async (hit) => {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
|
||||
return SkinResolver.fromModel(model);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the database using SQLite's FTS (full text search) index.
|
||||
*
|
||||
* Useful for locating a particular skin.
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export async function search_classic_skins(
|
||||
query: string,
|
||||
first: Int = 10,
|
||||
offset: Int = 0,
|
||||
ctx: UserContext
|
||||
): Promise<Array<ClassicSkinResolver | null>> {
|
||||
if (first > 1000) {
|
||||
throw new Error("Can only query 1000 records via search.");
|
||||
}
|
||||
|
||||
// const skins = await knex("skin_search")
|
||||
// .select("skin_search.skin_md5")
|
||||
// .leftJoin("skins", "skin_search.skin_md5", "skins.md5")
|
||||
// .leftJoin("skin_reviews", "skins.md5", "skin_reviews.skin_md5")
|
||||
// .where("skins.skin_type", "=", 1)
|
||||
// .orderByRaw("CASE WHEN skin_reviews.review = 'NSFW' THEN 1 ELSE 0 END")
|
||||
// .limit(first)
|
||||
// .offset(offset)
|
||||
// .whereRaw("skin_search MATCH ?", query);
|
||||
|
||||
const skins = await knex("skin_search")
|
||||
.select("skin_md5")
|
||||
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
|
||||
.where("skins.skin_type", "=", 1)
|
||||
.limit(first)
|
||||
.offset(offset)
|
||||
.whereRaw("skin_search MATCH ?", query);
|
||||
|
||||
return Promise.all(
|
||||
skins.map(async (hit) => {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
|
||||
return new ClassicSkinResolver(model);
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* A random skin that needs to be reviewed
|
||||
* @gqlField */
|
||||
export async function skin_to_review(
|
||||
_: Query,
|
||||
{ ctx }: Ctx
|
||||
): Promise<ISkin | null> {
|
||||
* @gqlQueryField */
|
||||
export async function skin_to_review(ctx: UserContext): Promise<ISkin | null> {
|
||||
if (!ctx.authed()) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,27 @@
|
|||
import { Ctx } from "..";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import { knex } from "../../../db";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import { Query } from "./QueryResolver";
|
||||
import SkinResolver from "./SkinResolver";
|
||||
|
||||
/**
|
||||
* Get the status of a batch of uploads by md5s
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
|
||||
*/
|
||||
export async function upload_statuses_by_md5(
|
||||
_: Query,
|
||||
{ md5s }: { md5s: string[] },
|
||||
{ ctx }: Ctx
|
||||
md5s: string[],
|
||||
ctx: UserContext
|
||||
): Promise<Array<SkinUpload | null>> {
|
||||
return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of a batch of uploads by ids
|
||||
* @gqlField */
|
||||
* @gqlQueryField */
|
||||
export async function upload_statuses(
|
||||
_: Query,
|
||||
{ ids }: { ids: string[] },
|
||||
{ ctx }: Ctx
|
||||
ids: string[],
|
||||
ctx: UserContext
|
||||
): Promise<Array<SkinUpload | null>> {
|
||||
return _upload_statuses({ keyName: "id", keys: ids }, ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as S3 from "../../../s3";
|
|||
import * as Skins from "../../../data/skins";
|
||||
import { processUserUploads } from "../../processUserUploads";
|
||||
import { Ctx } from "..";
|
||||
import { Mutation } from "./MutationResolver";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
|
||||
// We don't use a resolver here, just return the value directly.
|
||||
/**
|
||||
|
|
@ -46,8 +46,8 @@ class UploadMutationResolver {
|
|||
* @gqlField
|
||||
*/
|
||||
async get_upload_urls(
|
||||
{ files }: { files: UploadUrlRequest[] },
|
||||
{ ctx }: Ctx
|
||||
files: UploadUrlRequest[],
|
||||
ctx: UserContext
|
||||
): Promise<Array<UploadUrl | null>> {
|
||||
const missing: UploadUrl[] = [];
|
||||
await Parallel.each(
|
||||
|
|
@ -70,7 +70,8 @@ class UploadMutationResolver {
|
|||
* @gqlField
|
||||
*/
|
||||
async report_skin_uploaded(
|
||||
{ id, md5 }: { id: string; md5: string },
|
||||
id: string,
|
||||
md5: string,
|
||||
req: Ctx
|
||||
): Promise<boolean> {
|
||||
// TODO: Validate md5 and id;
|
||||
|
|
@ -83,7 +84,7 @@ class UploadMutationResolver {
|
|||
|
||||
/**
|
||||
* Mutations for the upload flow
|
||||
* @gqlField */
|
||||
export async function upload(_: Mutation): Promise<UploadMutationResolver> {
|
||||
* @gqlMutationField */
|
||||
export async function upload(): Promise<UploadMutationResolver> {
|
||||
return new UploadMutationResolver();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { Ctx } from "..";
|
||||
import { Query } from "./QueryResolver";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
|
||||
/** @gqlType User */
|
||||
export default class UserResolver {
|
||||
/** @gqlField */
|
||||
username({ ctx }: Ctx): string | null {
|
||||
username(ctx: UserContext): string | null {
|
||||
return ctx.username;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently authenticated user, if any.
|
||||
* @gqlField
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export function me(_: Query): UserResolver | null {
|
||||
export function me(): UserResolver | null {
|
||||
return new UserResolver();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +436,12 @@ type Query {
|
|||
"""
|
||||
node(id: ID!): Node
|
||||
"""
|
||||
Search the database using SQLite's FTS (full text search) index.
|
||||
|
||||
Useful for locating a particular skin.
|
||||
"""
|
||||
search_classic_skins(first: Int! = 10, offset: Int! = 0, query: String!): [ClassicSkin] @semanticNonNull
|
||||
"""
|
||||
Search the database using the Algolia search index used by the Museum.
|
||||
|
||||
Useful for locating a particular skin.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import * as Skins from "../data/skins";
|
||||
import S3 from "../s3";
|
||||
import { addSkinFromBuffer } from "../addSkin";
|
||||
import { EventHandler } from "./app";
|
||||
import { EventHandler } from "./types";
|
||||
import DiscordEventHandler from "./DiscordEventHandler";
|
||||
|
||||
async function* reportedUploads(): AsyncGenerator<
|
||||
|
|
@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60;
|
|||
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject("timeout"), duration)
|
||||
),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject("timeout"), duration);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,6 @@ async function processGivenUserUploads(
|
|||
message: e.message,
|
||||
} as const;
|
||||
eventHandler(action);
|
||||
throw e;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,332 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import asyncHandler from "express-async-handler";
|
||||
import SkinModel from "../data/SkinModel";
|
||||
import * as Skins from "../data/skins";
|
||||
import {
|
||||
DISCORD_CLIENT_ID,
|
||||
DISCORD_REDIRECT_URL,
|
||||
LOGIN_REDIRECT_URL,
|
||||
} from "../config";
|
||||
import S3 from "../s3";
|
||||
import LRU from "lru-cache";
|
||||
import { MuseumPage } from "../data/skins";
|
||||
import { processUserUploads } from "./processUserUploads";
|
||||
import { auth } from "./auth";
|
||||
import * as Parallel from "async-parallel";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const options = {
|
||||
max: 100,
|
||||
maxAge: 1000 * 60 * 60,
|
||||
};
|
||||
let skinCount: number | null = null;
|
||||
const cache = new LRU<string, MuseumPage>(options);
|
||||
|
||||
// Purposefully REST
|
||||
router.get(
|
||||
"/auth/",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.redirect(
|
||||
302,
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(
|
||||
DISCORD_CLIENT_ID
|
||||
)}&redirect_uri=${encodeURIComponent(
|
||||
DISCORD_REDIRECT_URL
|
||||
)}&response_type=code&scope=identify%20guilds`
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/authed/",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ username: req.ctx.username });
|
||||
})
|
||||
);
|
||||
|
||||
// Purposefully REST
|
||||
router.get(
|
||||
"/auth/discord",
|
||||
asyncHandler(async (req, res) => {
|
||||
const code = req.query.code as string | undefined;
|
||||
|
||||
if (code == null) {
|
||||
res.status(400).send({ message: "Expected to get a code" });
|
||||
return;
|
||||
}
|
||||
const username = await auth(code);
|
||||
if (username == null) {
|
||||
res.status(400).send({ message: "Invalid code" });
|
||||
return;
|
||||
}
|
||||
req.session.username = username;
|
||||
|
||||
// TODO: What about dev?
|
||||
res.redirect(302, LOGIN_REDIRECT_URL);
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/skins/",
|
||||
asyncHandler(async (req, res) => {
|
||||
if (skinCount == null) {
|
||||
skinCount = await Skins.getClassicSkinCount();
|
||||
}
|
||||
const { offset = 0, first = 100 } = req.query;
|
||||
const key = req.originalUrl;
|
||||
const cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
req.log(`Cache hit for ${key}`);
|
||||
res.json({ skinCount, skins: cached });
|
||||
return;
|
||||
}
|
||||
req.log(`Getting offset: ${offset}, first: ${first}`);
|
||||
|
||||
const start = Date.now();
|
||||
const skins = await Skins.getMuseumPage({
|
||||
offset: Number(offset),
|
||||
first: Number(first),
|
||||
});
|
||||
req.log(`Query took ${(Date.now() - start) / 1000}`);
|
||||
req.log(`Cache set for ${key}`);
|
||||
cache.set(key, skins);
|
||||
res.json({ skinCount, skins });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.post(
|
||||
"/skins/get_upload_urls",
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body.skins as { [md5: string]: string };
|
||||
const missing = {};
|
||||
await Parallel.each(
|
||||
Object.entries(payload),
|
||||
async ([md5, filename]) => {
|
||||
if (!(await SkinModel.exists(req.ctx, md5))) {
|
||||
const id = await Skins.recordUserUploadRequest(md5, filename);
|
||||
const url = S3.getSkinUploadUrl(md5, id);
|
||||
missing[md5] = { id, url };
|
||||
}
|
||||
},
|
||||
5
|
||||
);
|
||||
res.json(missing);
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.post(
|
||||
"/feedback",
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body as {
|
||||
email?: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
};
|
||||
req.notify({
|
||||
type: "GOT_FEEDBACK",
|
||||
url: payload.url,
|
||||
message: payload.message,
|
||||
email: payload.email,
|
||||
});
|
||||
res.json({ message: "sent" });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecate Use GraphQL
|
||||
router.post(
|
||||
"/skins/status",
|
||||
asyncHandler(async (req, res) => {
|
||||
const statuses = await Skins.getUploadStatuses(req.body.hashes);
|
||||
res.json(statuses);
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/skins/:md5",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
req.log(`Details for hash "${md5}" NOT FOUND`);
|
||||
res.status(404).json();
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
md5: skin.getMd5(),
|
||||
nsfw: await skin.getIsNsfw(),
|
||||
fileName: await skin.getFileName(),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/skins/:md5/metadata",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
req.log(`Details for hash "${md5}" NOT FOUND`);
|
||||
res.status(404).json();
|
||||
return;
|
||||
}
|
||||
const [nsfw, fileName, readme] = await Promise.all([
|
||||
skin.getIsNsfw(),
|
||||
skin.getFileName(),
|
||||
skin.getReadme(),
|
||||
]);
|
||||
res.json({
|
||||
md5: skin.getMd5(),
|
||||
nsfw,
|
||||
fileName,
|
||||
readme,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/skins/:md5/debug",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
req.log(`Details for hash "${md5}" NOT FOUND`);
|
||||
res.status(404).json();
|
||||
return;
|
||||
}
|
||||
res.json(await skin.debug());
|
||||
})
|
||||
);
|
||||
|
||||
function requireAuthed(req, res, next) {
|
||||
if (!req.ctx.authed()) {
|
||||
res.status(403);
|
||||
res.send({ message: "You must be logged in" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/to_review",
|
||||
requireAuthed,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { filename, md5 } = await Skins.getSkinToReview();
|
||||
res.json({ filename, md5 });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.post(
|
||||
"/skins/:md5/reject",
|
||||
requireAuthed,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
req.log(`Rejecting skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
res.status(404).send("Skin not found");
|
||||
return;
|
||||
}
|
||||
await Skins.reject(req.ctx, md5);
|
||||
req.notify({ type: "REJECTED_SKIN", md5 });
|
||||
res.send({ message: "The skin has been rejected." });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.post(
|
||||
"/skins/:md5/approve",
|
||||
requireAuthed,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
req.log(`Approving skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
res.status(404).send("Skin not found");
|
||||
return;
|
||||
}
|
||||
await Skins.approve(req.ctx, md5);
|
||||
req.notify({ type: "APPROVED_SKIN", md5 });
|
||||
res.send({ message: "The skin has been approved." });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
// Unlike /report, this marks the skin NSFW right away without sending to
|
||||
// Discord. Because of this, it requires auth.
|
||||
router.post(
|
||||
"/skins/:md5/nsfw",
|
||||
requireAuthed,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
req.log(`Approving skin with hash "${md5}"`);
|
||||
const skin = await SkinModel.fromMd5(req.ctx, md5);
|
||||
if (skin == null) {
|
||||
res.status(404).send("Skin not found");
|
||||
return;
|
||||
}
|
||||
await Skins.markAsNSFW(req.ctx, md5);
|
||||
req.notify({ type: "MARKED_SKIN_NSFW", md5 });
|
||||
res.send({ message: "The skin has been marked as NSFW." });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.post(
|
||||
"/skins/:md5/report",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
req.log(`Reporting skin with hash "${md5}"`);
|
||||
// Blow up if there is no skin with this hash
|
||||
await SkinModel.fromMd5Assert(req.ctx, md5);
|
||||
req.notify({ type: "REVIEW_REQUESTED", md5 });
|
||||
res.send("The skin has been reported and will be reviewed shortly.");
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
// User reports that they uploaded a skin
|
||||
router.post(
|
||||
"/skins/:md5/uploaded",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { md5 } = req.params;
|
||||
const id = req.query.id as string;
|
||||
if (id == null) {
|
||||
throw new Error("Missing upload id");
|
||||
}
|
||||
// TODO: Validate md5 and id;
|
||||
await Skins.recordUserUploadComplete(md5, id);
|
||||
// Don't await, just kick off the task.
|
||||
processUserUploads(req.notify);
|
||||
res.json({ done: true });
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Use GraphQL
|
||||
router.get(
|
||||
"/approved",
|
||||
asyncHandler(async (req, res) => {
|
||||
const approved = await Skins.getAllApproved();
|
||||
res.json(approved);
|
||||
})
|
||||
);
|
||||
|
||||
// @deprecated Special purpose URL
|
||||
router.get(
|
||||
"/stylegan.json",
|
||||
asyncHandler(async (req, res) => {
|
||||
const images = await Skins.getAllClassicScreenshotUrls();
|
||||
res.json(images);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// import Sentry from "@sentry/node";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import { createApp } from "./app";
|
||||
import DiscordEventHandler from "./DiscordEventHandler";
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
||||
|
||||
const handler = new DiscordEventHandler();
|
||||
|
||||
// GO!
|
||||
const app = createApp({
|
||||
eventHandler: (action) => handler.handle(action),
|
||||
logger: {
|
||||
log: (message, context) => console.log(message, context),
|
||||
logError: (message, context) => console.error(message, context),
|
||||
},
|
||||
});
|
||||
app.listen(port, () => {
|
||||
console.log(
|
||||
`Winamp Skin Museum database API app listening on http://localhost:${port}`
|
||||
);
|
||||
console.log(`Explore: http://localhost:${port}/graphql`);
|
||||
});
|
||||
|
||||
// Initialize Sentry after we start listening. Any crash at start time will appear in the console and we'll notice.
|
||||
/*
|
||||
Sentry.init({
|
||||
dsn:
|
||||
"https://0e6bc841b4f744b2953a1fe5981effe6@o68382.ingest.sentry.io/5508241",
|
||||
|
||||
// We recommend adjusting this value in production, or using tracesSampler
|
||||
// for finer control
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
*/
|
||||
54
packages/skin-database/api/types.ts
Normal file
54
packages/skin-database/api/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import UserContext from "../data/UserContext";
|
||||
|
||||
export type ApiAction =
|
||||
| { type: "REVIEW_REQUESTED"; md5: string }
|
||||
| { type: "REJECTED_SKIN"; md5: string }
|
||||
| { type: "APPROVED_SKIN"; md5: string }
|
||||
| { type: "MARKED_SKIN_NSFW"; md5: string }
|
||||
| { type: "SKIN_UPLOADED"; md5: string }
|
||||
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
|
||||
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
|
||||
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
|
||||
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
|
||||
| {
|
||||
type: "GOT_FEEDBACK";
|
||||
message: string;
|
||||
email?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
| {
|
||||
type: "SYNCED_TO_ARCHIVE";
|
||||
successes: number;
|
||||
errors: number;
|
||||
skips: number;
|
||||
}
|
||||
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
|
||||
| {
|
||||
type: "POPULAR_TWEET";
|
||||
bracket: number;
|
||||
url: string;
|
||||
likes: number;
|
||||
date: Date;
|
||||
}
|
||||
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
|
||||
|
||||
export type EventHandler = (event: ApiAction) => void;
|
||||
export type Logger = {
|
||||
log(message: string, context: any): void;
|
||||
logError(message: string, context: any): void;
|
||||
};
|
||||
|
||||
// Add UserContext to req objects globally
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
ctx: UserContext;
|
||||
notify(action: ApiAction): void;
|
||||
log(message: string): void;
|
||||
logError(message: string): void;
|
||||
session: {
|
||||
username: string | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
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 />;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "../skinMetadata";
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { hash, fileName: _fileName } = await params;
|
||||
return generateSkinPageMetadata(hash);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
11
packages/skin-database/app/(legacy)/skin/[hash]/page.tsx
Normal file
11
packages/skin-database/app/(legacy)/skin/[hash]/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "./skinMetadata";
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { hash } = await params;
|
||||
return generateSkinPageMetadata(hash);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Metadata } from "next";
|
||||
import SkinModel from "../../../../data/SkinModel";
|
||||
import UserContext from "../../../../data/UserContext";
|
||||
|
||||
export async function generateSkinPageMetadata(
|
||||
hash: string
|
||||
): Promise<Metadata> {
|
||||
const skin = await SkinModel.fromMd5Assert(new UserContext(), hash);
|
||||
const fileName = await skin.getFileName();
|
||||
const readme = await skin.getReadme();
|
||||
|
||||
const imageUrl = `https://skin-museum-og-captbaritone-webamp.vercel.app/api/og?md5=${hash}`;
|
||||
|
||||
const images = [
|
||||
{
|
||||
alt: `Screenshot of the Winamp skin ${fileName}.`,
|
||||
url: imageUrl,
|
||||
width: 1200,
|
||||
height: 600,
|
||||
},
|
||||
];
|
||||
|
||||
const title = `${fileName} - Winamp Skin Museum`;
|
||||
const description =
|
||||
readme == null
|
||||
? `The Winamp Skin "${fileName}" in the Winamp Skin Museum. Explore skins, view details, and interact with previews.`
|
||||
: readme.slice(0, 300);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: skin.getMuseumUrl(),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
type: "website",
|
||||
siteName: "Winamp Skin Museum",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@winampskins",
|
||||
title,
|
||||
description,
|
||||
creator: "@captbaritone",
|
||||
images,
|
||||
},
|
||||
};
|
||||
}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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