mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 18:25:30 +00:00
Compare commits
207 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe3235d51 | ||
|
|
54dbe369d4 | ||
|
|
bce845962f | ||
|
|
4b784b6eaf | ||
|
|
52f12327fa | ||
|
|
1d10f7a1f4 | ||
|
|
99f06d3bfc | ||
|
|
162025f8a0 | ||
|
|
f600fb0344 | ||
|
|
04c396ed1f | ||
|
|
f74d7a6cdf | ||
|
|
1fb930cd63 | ||
|
|
8d4ff41f42 | ||
|
|
e062a51a88 | ||
|
|
0895f9191f | ||
|
|
3b4e5b17c3 | ||
|
|
91618c9c6b | ||
|
|
6c732f8e24 | ||
|
|
d87cb6ffa3 | ||
|
|
6997c852f9 | ||
|
|
61476591f8 | ||
|
|
33003a8f8f | ||
|
|
50a7c2df49 | ||
|
|
1f875a6155 | ||
|
|
26a6002ce8 | ||
|
|
bd6c978d79 | ||
|
|
18ee5418b6 | ||
|
|
af21934296 | ||
|
|
4b793c30b2 | ||
|
|
a6b0350a00 | ||
|
|
964a7c5f2f | ||
|
|
b00e359a78 | ||
|
|
d159308352 | ||
|
|
d687f4b06c | ||
|
|
8fa7701b47 | ||
|
|
1da77a640a | ||
|
|
642fb964d6 | ||
|
|
8358d4843c | ||
|
|
bbd1d1224e | ||
|
|
b672de2515 | ||
|
|
014c8eab28 | ||
|
|
a56cbc54c5 | ||
|
|
8efe121f3c | ||
|
|
c778464c42 | ||
|
|
d6245c7c7e | ||
|
|
f1339901e6 | ||
|
|
811fc977c4 | ||
|
|
7afe3bd45b | ||
|
|
608242b200 | ||
|
|
f3054192e6 | ||
|
|
0b2ff44b1c | ||
|
|
52ff84d29b | ||
|
|
fbe3a0090f | ||
|
|
0705e9d89e | ||
|
|
340e2249ae | ||
|
|
4b405bc831 | ||
|
|
50d5dbbf4f | ||
|
|
96ffdcda59 | ||
|
|
e5ed88c8ec | ||
|
|
3c882550e3 | ||
|
|
6434ecc626 | ||
|
|
a20bab1877 | ||
|
|
a0cecb8f93 | ||
|
|
0608b2f9c6 | ||
|
|
27ba138a3e | ||
|
|
cb651adfaf | ||
|
|
224b4b8058 | ||
|
|
f35f1242ca | ||
|
|
acff24b7bb | ||
|
|
e99b2ab6f7 | ||
|
|
a30ab82ccc | ||
|
|
eaba9667e2 | ||
|
|
41cfbbb63c | ||
|
|
6f8f85c865 | ||
|
|
e82db4cddd | ||
|
|
0d7cb8285f | ||
|
|
a4dec85406 | ||
|
|
5e88d4e37b | ||
|
|
9b03dcd077 | ||
|
|
cb2948c2d3 | ||
|
|
b7d759618e | ||
|
|
910995d469 | ||
|
|
35e6128224 | ||
|
|
b02fc7a5c6 | ||
|
|
161c14b598 | ||
|
|
7cdc35b93a | ||
|
|
19668851d6 | ||
|
|
b76491001c | ||
|
|
dbc64a8287 | ||
|
|
9f789ae2ba | ||
|
|
95e96ddb02 | ||
|
|
12c31f65ae | ||
|
|
af1f294ada | ||
|
|
a06485f1be | ||
|
|
a4f14d9a86 | ||
|
|
4ead993d64 | ||
|
|
9d88134988 | ||
|
|
157f8d28df | ||
|
|
c55212b363 | ||
|
|
15b15d081a | ||
|
|
51859506db | ||
|
|
2dee12e9e0 | ||
|
|
daf06567b0 | ||
|
|
3a1ec73825 | ||
|
|
b090082dfa | ||
|
|
6ce866f48c | ||
|
|
26e8e6dfeb | ||
|
|
8425adaeff | ||
|
|
c2c067588d | ||
|
|
f45736c0b6 | ||
|
|
28c36e5141 | ||
|
|
99e4ae4f90 | ||
|
|
ce8c4b1bcc | ||
|
|
fee8030882 | ||
|
|
46ad06054e | ||
|
|
60c94b4f00 | ||
|
|
e641c61b64 | ||
|
|
f471cd1e69 | ||
|
|
aef14f205d | ||
|
|
dbf54596e4 | ||
|
|
f2fd00b65b | ||
|
|
b99ea291eb | ||
|
|
8abcf9d139 | ||
|
|
f9f892bde1 | ||
|
|
08ec7ce69f | ||
|
|
bcaa8dfdc6 | ||
|
|
9aa4f59db5 | ||
|
|
c78b1abe92 | ||
|
|
d1f6ef0089 | ||
|
|
8eae420851 | ||
|
|
704112b333 | ||
|
|
274abd9090 | ||
|
|
c6d0c2717c | ||
|
|
c0d4960dd3 | ||
|
|
48ef4eeff2 | ||
|
|
aa7ea12b41 | ||
|
|
b508663db9 | ||
|
|
eee17f0d25 | ||
|
|
b1ec6460b2 | ||
|
|
833060e1ae | ||
|
|
3e0417267a | ||
|
|
d4a841846c | ||
|
|
7b87c2302c | ||
|
|
b230cc66c5 | ||
|
|
1cb0991db7 | ||
|
|
1d39600284 | ||
|
|
14c0d24a47 | ||
|
|
e52900d4fc | ||
|
|
7e159f2173 | ||
|
|
bb98aba71a | ||
|
|
cfa67c4bc5 | ||
|
|
a86226bd6b | ||
|
|
854e2c7998 | ||
|
|
f4321173df | ||
|
|
d060b9042b | ||
|
|
50e6e21e2e | ||
|
|
e06d3b9b3e | ||
|
|
b5bf64a55d | ||
|
|
1ba58c81e1 | ||
|
|
9d0cdc6ed8 | ||
|
|
90625614f0 | ||
|
|
b66667019c | ||
|
|
e043a1b5b8 | ||
|
|
366e79525e | ||
|
|
eb0898fe4e | ||
|
|
3feae65585 | ||
|
|
7ededf9bf5 | ||
|
|
067623811c | ||
|
|
a2eb557599 | ||
|
|
2ea015c504 | ||
|
|
b63cec5cbd | ||
|
|
a618b049eb | ||
|
|
4c07731d76 | ||
|
|
ce483b8df5 | ||
|
|
2be1e38bce | ||
|
|
c1d68b5946 | ||
|
|
f4daf821c8 | ||
|
|
afc8b038c8 | ||
|
|
aa006df23e | ||
|
|
6ad374934f | ||
|
|
b5dc838b8a | ||
|
|
72b902e5f4 | ||
|
|
09c26754aa | ||
|
|
333bb96c5d | ||
|
|
469b136589 | ||
|
|
a180155fb9 | ||
|
|
2d572d561e | ||
|
|
d5ea998198 | ||
|
|
8b218e8e67 | ||
|
|
62c5af1dfb | ||
|
|
1fb4a4fb5b | ||
|
|
4ad4ea74eb | ||
|
|
fd12770ad5 | ||
|
|
6432753d20 | ||
|
|
415a06f80f | ||
|
|
a0f5cd2358 | ||
|
|
23c34434f3 | ||
|
|
5a312bd9c6 | ||
|
|
7ff8aab8af | ||
|
|
f0f8e88dd0 | ||
|
|
dc59239a83 | ||
|
|
91e6bd3902 | ||
|
|
359b3258ed | ||
|
|
624573a4d9 | ||
|
|
f860e72971 | ||
|
|
d007dadd5a | ||
|
|
1dc8c15bbb |
342 changed files with 33552 additions and 259616 deletions
100
.eslintrc
100
.eslintrc
|
|
@ -4,15 +4,11 @@
|
|||
"jsx": true,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
"experimentalObjectRestSpread": true
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": ["prettier"],
|
||||
"plugins": ["prettier", "@typescript-eslint"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "15.2"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".ts", ".tsx"]
|
||||
|
|
@ -21,27 +17,85 @@
|
|||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"amd": true,
|
||||
"es6": true,
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"window": true,
|
||||
"document": true,
|
||||
"console": true,
|
||||
"navigator": true,
|
||||
"alert": true,
|
||||
"Blob": true,
|
||||
"fetch": true,
|
||||
"FileReader": true,
|
||||
"Element": true,
|
||||
"AudioNode": true,
|
||||
"MutationObserver": true,
|
||||
"Image": true,
|
||||
"location": true
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"no-constant-binary-expression": "error"
|
||||
"no-constant-binary-expression": "error",
|
||||
"array-callback-return": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"block-scoped-var": "warn",
|
||||
"camelcase": "error",
|
||||
"constructor-super": "error",
|
||||
"dot-notation": "error",
|
||||
"eqeqeq": ["error", "smart"],
|
||||
"guard-for-in": "error",
|
||||
"max-depth": ["warn", 4],
|
||||
"new-cap": "error",
|
||||
"no-caller": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-div-regex": "warn",
|
||||
"no-dupe-args": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-eval": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extend-native": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-extra-semi": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inner-declarations": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-nested-ternary": "warn",
|
||||
"no-new-object": "error",
|
||||
"no-new-symbol": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-octal": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-proto": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-shadow": "warn",
|
||||
"no-this-before-super": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-var": "error",
|
||||
"no-with": "error",
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-const": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "warn",
|
||||
"radix": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
162
.github/workflows/ci.yml
vendored
162
.github/workflows/ci.yml
vendored
|
|
@ -1,85 +1,125 @@
|
|||
name: CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
# Main CI job - using Turborepo for dependency management
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
version: 9.12.0
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
- name: Install Dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build all packages
|
||||
run: |
|
||||
yarn workspace ani-cursor build
|
||||
yarn workspace webamp build
|
||||
yarn workspace webamp build-library
|
||||
- name: Lint
|
||||
npx turbo build build-library
|
||||
env:
|
||||
NODE_ENV: production
|
||||
- name: Lint and type-check
|
||||
run: |
|
||||
yarn lint
|
||||
yarn workspace webamp type-check
|
||||
- name: Run Unit Tests
|
||||
npx turbo lint type-check
|
||||
- name: Validate Grats generated files are up-to-date
|
||||
run: ./scripts/validate-grats.sh
|
||||
- name: Run tests
|
||||
run: |
|
||||
touch packages/skin-database/config.js
|
||||
yarn test
|
||||
yarn workspace webamp test
|
||||
# - name: Run Integration Tests
|
||||
# run: yarn workspace webamp integration-tests
|
||||
# env:
|
||||
# CI: true
|
||||
# - name: Upload Screenshot Diffs
|
||||
# if: failure()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: image_diffs
|
||||
# path: packages/webamp/js/__tests__/__image_snapshots__/__diff_output__/
|
||||
# - name: Generate New Screenshots
|
||||
# if: failure()
|
||||
# run: |
|
||||
# yarn workspace webamp integration-tests -u
|
||||
# - name: Upload New Screenshots
|
||||
# if: failure()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: new_images
|
||||
# path: packages/webamp/js/__tests__/__image_snapshots__/
|
||||
main-release:
|
||||
name: Publish to NPM
|
||||
npx turbo test -- --maxWorkers=2
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- name: Cache build artifacts for release
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/ani-cursor/dist
|
||||
packages/winamp-eqf/built
|
||||
packages/webamp/built
|
||||
key: release-artifacts-${{ github.sha }}
|
||||
# Release job - publish packages to NPM
|
||||
release:
|
||||
name: Publish packages to NPM
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
|
||||
needs: [build-and-test]
|
||||
needs: [ci]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.12.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: "pnpm"
|
||||
- name: Update npm to latest version
|
||||
run: npm install -g npm@latest
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Set version
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Restore build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/ani-cursor/dist
|
||||
packages/winamp-eqf/built
|
||||
packages/webamp/built
|
||||
key: release-artifacts-${{ github.sha }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Set version for all packages
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
echo "Setting version to 0.0.0-next-${RELEASE_COMMIT_SHA::7}"
|
||||
yarn workspace webamp version --new-version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
cd packages/webamp && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
cd ../ani-cursor && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ github.sha }}
|
||||
- name: Build release version
|
||||
- name: Set version for tagged release
|
||||
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
|
||||
- name: Publish to npm
|
||||
working-directory: ./packages/webamp
|
||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
# Note: This also triggers a build
|
||||
run: |
|
||||
npm publish ${TAG}
|
||||
env:
|
||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Setting version to $VERSION for tagged release"
|
||||
cd packages/webamp && npm version $VERSION --no-git-tag-version
|
||||
cd ../ani-cursor && npm version $VERSION --no-git-tag-version
|
||||
cd ../winamp-eqf && npm version $VERSION --no-git-tag-version
|
||||
# TODO: Update version number in webampLazy.tsx if needed
|
||||
- name: Publish ani-cursor to npm
|
||||
working-directory: ./packages/ani-cursor
|
||||
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
- name: Publish winamp-eqf to npm
|
||||
working-directory: ./packages/winamp-eqf
|
||||
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
- name: Publish webamp to npm
|
||||
working-directory: ./packages/webamp
|
||||
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||
# Use pre-built artifacts instead of rebuilding
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
|
|
|
|||
24
.github/workflows/code-size.yml
vendored
24
.github/workflows/code-size.yml
vendored
|
|
@ -4,13 +4,23 @@ on: [pull_request]
|
|||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
build-script: "deploy"
|
||||
pattern: "./packages/webamp/built/*bundle.min.js"
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.12.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "pnpm"
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
build-script: "deploy"
|
||||
pattern: "./packages/webamp/built/*bundle.min.js"
|
||||
|
|
|
|||
29
.github/workflows/ia-integration-tests.yml
vendored
29
.github/workflows/ia-integration-tests.yml
vendored
|
|
@ -1,29 +0,0 @@
|
|||
name: "Internet Archive Integration Tests"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# schedule:
|
||||
# - cron: "0 8 * * *"
|
||||
|
||||
jobs:
|
||||
ia-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cd packages/archive-org-webamp-integration-tests
|
||||
yarn
|
||||
node ./index.js
|
||||
env:
|
||||
CI: true
|
||||
- uses: actions/upload-artifact@v1
|
||||
if: failure()
|
||||
with:
|
||||
name: error
|
||||
path: packages/webamp/experiments/archive-org-integration-tests/error.png
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
node_modules
|
||||
.vscode
|
||||
.parcel-cache
|
||||
.idea
|
||||
dist
|
||||
parcel-bundle-reports
|
||||
|
||||
# Turborepo cache
|
||||
.turbo
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "@parcel/config-default"
|
||||
}
|
||||
147392
.yarn/releases/yarn-1.22.10.cjs
vendored
147392
.yarn/releases/yarn-1.22.10.cjs
vendored
File diff suppressed because one or more lines are too long
5
.yarnrc
5
.yarnrc
|
|
@ -1,5 +0,0 @@
|
|||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
yarn-path ".yarn/releases/yarn-1.22.10.cjs"
|
||||
67
README.md
67
README.md
|
|
@ -3,28 +3,16 @@
|
|||
|
||||
# Webamp
|
||||
|
||||
A reimplementation of Winamp 2.9 in HTML5 and JavaScript with full skin support.
|
||||
A reimplementation of Winamp in HTML5 and JavaScript with full skin support.
|
||||
As seen on [TechCrunch], [Motherboard], [Gizmodo], Hacker News ([1], [2], [3], [4]), and [elsewhere](./packages/webamp/docs/press.md).
|
||||
|
||||
[](https://webamp.org)
|
||||
|
||||
Check out this [Twitter thread](https://twitter.com/captbaritone/status/961274714013319168) for an illustrated list of features. Works in modern versions of Edge, Firefox, Safari and Chrome. IE is [not supported](http://caniuse.com/#feat=audio-api).
|
||||
|
||||
## Add Webamp to Your Site
|
||||
## Read the docs
|
||||
|
||||
Here is the **most minimal** example of adding Webamp to a page:
|
||||
|
||||
```HTML
|
||||
<div id="app"></div>
|
||||
<script src="https://unpkg.com/webamp"></script>
|
||||
<script>
|
||||
const app = document.getElementById("app")
|
||||
const webamp = new Webamp();
|
||||
webamp.renderWhenReady(app);
|
||||
</script>
|
||||
```
|
||||
|
||||
For more examples, including how to add audio files, check out [`examples/` directory](./examples) and the [API documentation](./packages/webamp/docs/usage.md).
|
||||
**The [Webamp Documentation](https://docs.webamp.org) site contains detailed instructions showing how to add Webamp to your site and customize it to meet your needs.**
|
||||
|
||||
## About This Repository
|
||||
|
||||
|
|
@ -32,11 +20,10 @@ Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in
|
|||
|
||||
- [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
|
||||
- [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
|
||||
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
|
||||
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
|
||||
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
|
||||
- [`packages/skin-museum-client`](https://github.com/captbaritone/webamp/tree/master/packages/skin-museum-client): The front-end component of https://skins.webamp.org.
|
||||
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
|
||||
- [`packages/archive-org-webamp-integration-tests`](https://github.com/captbaritone/webamp/tree/master/packages/archive-org-webamp-integration-tests): An integration that confirms that archive.org's Webamp integration is working as expected
|
||||
- [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
|
||||
- [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module
|
||||
|
||||
|
|
@ -78,6 +65,52 @@ Nullsoft, the code within this project is released under the [MIT
|
|||
License](LICENSE.txt). That being said, if you do anything interesting with
|
||||
this code, please let me know. I'd love to see it.
|
||||
|
||||
## Development
|
||||
|
||||
This repository uses [Turborepo](https://turbo.build/) for efficient monorepo management. Turborepo provides intelligent caching and parallel execution of tasks across all packages.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build all packages (automatically handles dependencies)
|
||||
npx turbo build
|
||||
|
||||
# Build library bundles for packages that need them
|
||||
npx turbo build-library
|
||||
|
||||
# Run all tests
|
||||
npx turbo test
|
||||
|
||||
# Lint and type-check all packages
|
||||
npx turbo lint type-check
|
||||
|
||||
# Work on a specific package and its dependencies
|
||||
npx turbo dev --filter="webamp"
|
||||
```
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
The monorepo dependency graph is automatically managed by Turborepo:
|
||||
|
||||
- `ani-cursor` and `winamp-eqf` are standalone packages built with TypeScript
|
||||
- `webamp` depends on both `ani-cursor` and `winamp-eqf` for workspace linking
|
||||
- All packages are built in the correct topological order
|
||||
- Builds are cached and only rebuild what has changed
|
||||
|
||||
### Available Tasks
|
||||
|
||||
- `build` - Main build output (Vite for demos, TypeScript compilation for libraries)
|
||||
- `build-library` - Library bundles for NPM publishing (only applies to `webamp`)
|
||||
- `test` - Run unit tests with Jest
|
||||
- `type-check` - TypeScript type checking without emitting files
|
||||
- `lint` - ESLint code quality checks
|
||||
- `dev` - Development server (for packages that support it)
|
||||
|
||||
For more details on individual packages, see their respective README files.
|
||||
|
||||
[techcrunch]: https://techcrunch.com/2018/02/09/whip-the-llamas-ass-with-this-javascript-winamp-emulator/
|
||||
[motherboard]: https://motherboard.vice.com/en_us/article/qvebbv/winamp-2-mp3-music-player-emulator
|
||||
[gizmodo]: https://gizmodo.com/winamp-2-has-been-immortalized-in-html5-for-your-pleasu-1655373653
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ module.exports = {
|
|||
"dist",
|
||||
// TODO: Add these as we can...
|
||||
"/packages/webamp/",
|
||||
"/packages/ani-cursor/",
|
||||
"/packages/winamp-eqf/",
|
||||
// TODO: Fix config import so that this can work.
|
||||
"/packages/webamp-modern/src/__tests__/integration*",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
#!/bin/bash
|
||||
yarn workspace ani-cursor build
|
||||
yarn workspace webamp build
|
||||
yarn workspace webamp build-library
|
||||
yarn workspace webamp-modern build
|
||||
mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern
|
||||
117
docs/typescript-checking.md
Normal file
117
docs/typescript-checking.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# TypeScript Checking Convention
|
||||
|
||||
This document describes the TypeScript checking convention established for the Webamp monorepo.
|
||||
|
||||
## Current Status
|
||||
|
||||
Each TypeScript-enabled package in the monorepo now has a consistent `type-check` script that performs type checking without emitting files.
|
||||
|
||||
**Progress: 5 out of 6 packages now passing! 🎉**
|
||||
|
||||
### Package Status
|
||||
|
||||
#### ✅ Passing Packages
|
||||
|
||||
- **webamp**: Clean TypeScript compilation
|
||||
- **ani-cursor**: Clean TypeScript compilation
|
||||
- **skin-database**: Clean TypeScript compilation (fixed JSZip types, Jest types, and Buffer compatibility issues)
|
||||
- **webamp-docs**: Clean TypeScript compilation
|
||||
- **winamp-eqf**: Clean TypeScript compilation (ES module with full type definitions)
|
||||
|
||||
#### ❌ Failing Packages (Need fixes)
|
||||
|
||||
- **webamp-modern**: 390+ TypeScript errors (conflicting type definitions, target issues)
|
||||
|
||||
## Convention
|
||||
|
||||
### Package-level Scripts
|
||||
|
||||
Each TypeScript package should have:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always use `tsc --noEmit` to avoid accidentally creating JavaScript files in source directories.
|
||||
|
||||
### Root-level Script
|
||||
|
||||
The root package.json contains a centralized script that runs type checking for all currently passing packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CI Integration
|
||||
|
||||
The CI workflow (`.github/workflows/ci.yml`) runs the centralized type-check command:
|
||||
|
||||
```yaml
|
||||
- name: Lint
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
## Adding New Packages
|
||||
|
||||
When adding a new TypeScript package to the type-check convention:
|
||||
|
||||
1. Add the `type-check` script to the package's `package.json`
|
||||
2. Ensure the package passes type checking: `pnpm --filter <package-name> type-check`
|
||||
3. Add the package to the root `type-check` script
|
||||
4. Test the full suite: `pnpm type-check`
|
||||
|
||||
## Fixing Failing Packages
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Missing Jest types** (Fixed in `skin-database`):
|
||||
|
||||
- Install `@types/jest` and configure proper Jest setup
|
||||
- Ensure test files are properly configured
|
||||
|
||||
2. **Missing package types** (Fixed in `skin-database`):
|
||||
|
||||
- Install missing dependencies like `jszip`, `react-redux`, `express`
|
||||
- Install corresponding `@types/` packages where needed
|
||||
- Note: Some packages like JSZip provide their own types
|
||||
|
||||
3. **Buffer compatibility issues** (Fixed in `skin-database`):
|
||||
|
||||
- Newer TypeScript versions require explicit casting for `fs.writeFileSync`
|
||||
- Use `new Uint8Array(buffer)` instead of raw `Buffer` objects
|
||||
|
||||
4. **Conflicting type definitions** (`webamp-modern`):
|
||||
|
||||
- Multiple versions of `@types/node` causing conflicts
|
||||
- Target configuration issues (ES5 vs ES2015+)
|
||||
- Dependency type mismatches
|
||||
|
||||
### Recommended Fix Strategy
|
||||
|
||||
1. Start with packages that have fewer errors
|
||||
2. Focus on one category of errors at a time
|
||||
3. Update TypeScript compiler target if needed (many errors require ES2015+)
|
||||
4. Ensure proper dependency management to avoid type conflicts
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Consistency**: All packages use the same type-checking approach
|
||||
- **CI Integration**: Automatic type checking prevents type errors from being merged
|
||||
- **Developer Experience**: Simple `pnpm type-check` command for full project validation
|
||||
- **Incremental**: Only includes packages that currently pass, allowing gradual improvement
|
||||
|
||||
## Future Work
|
||||
|
||||
- Fix remaining packages to include them in the type-check suite
|
||||
- Consider stricter TypeScript configurations for better type safety
|
||||
- Investigate automated type checking in development workflow
|
||||
24
examples/lazy/.gitignore
vendored
Normal file
24
examples/lazy/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
examples/lazy/README.md
Normal file
5
examples/lazy/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# `webamp/lazy` Example
|
||||
|
||||
Shows how it's possible to use Webamp with lazy loading and TypeScript. Uses [Vite](https://vitejs.dev/) for development and bundling.
|
||||
|
||||
Pay special attention to the versions used in `package.json` since some beta versions are required for this to work.
|
||||
13
examples/lazy/index.html
Normal file
13
examples/lazy/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Webamp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
examples/lazy/package.json
Normal file
22
examples/lazy/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "lazy",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"butterchurn": "3.0.0-beta.5",
|
||||
"butterchurn-presets": "3.0.0-beta.4",
|
||||
"jszip": "^3.10.1",
|
||||
"music-metadata": "^11.6.0",
|
||||
"webamp": "^2.2.0"
|
||||
}
|
||||
}
|
||||
57
examples/lazy/src/main.ts
Normal file
57
examples/lazy/src/main.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Webamp from "webamp/lazy";
|
||||
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
windowLayout: {
|
||||
main: { position: { left: 0, top: 0 } },
|
||||
equalizer: { position: { left: 0, top: 116 } },
|
||||
playlist: {
|
||||
position: { left: 0, top: 232 },
|
||||
size: { extraHeight: 4, extraWidth: 0 },
|
||||
},
|
||||
milkdrop: {
|
||||
position: { left: 275, top: 0 },
|
||||
size: { extraHeight: 12, extraWidth: 7 },
|
||||
},
|
||||
},
|
||||
requireJSZip: async () => {
|
||||
const JSZip = await import("jszip");
|
||||
return JSZip.default;
|
||||
},
|
||||
// @ts-ignore
|
||||
requireMusicMetadata: async () => {
|
||||
return await import("music-metadata");
|
||||
},
|
||||
__butterchurnOptions: {
|
||||
// @ts-ignore
|
||||
importButterchurn: () => import("butterchurn"),
|
||||
// @ts-ignore
|
||||
getPresets: async () => {
|
||||
const butterchurnPresets = await import(
|
||||
// @ts-ignore
|
||||
"butterchurn-presets/dist/base.js"
|
||||
);
|
||||
// Convert the presets object
|
||||
return Object.entries(butterchurnPresets.default).map(
|
||||
([name, preset]) => {
|
||||
return { name, butterchurnPresetObject: preset };
|
||||
}
|
||||
);
|
||||
},
|
||||
butterchurnOpen: true,
|
||||
},
|
||||
});
|
||||
|
||||
webamp.renderWhenReady(document.getElementById("app")!);
|
||||
1
examples/lazy/src/vite-env.d.ts
vendored
Normal file
1
examples/lazy/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
examples/lazy/tsconfig.json
Normal file
25
examples/lazy/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -9,7 +10,7 @@
|
|||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@next";
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
|
|
@ -34,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,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -10,50 +11,11 @@
|
|||
</div>
|
||||
<script type="module">
|
||||
/**
|
||||
* Webamp now includes an ESModule build, so you can import it directly. I
|
||||
* haven't validated full backwards compatibility with all the ways people
|
||||
* can import Webamp, so I haven't shipped this as the default build yet,
|
||||
* but it's on NPM as: `webamp@0.0.0-next-361ce79`.
|
||||
*
|
||||
* Changelog since the version you have can be found here:
|
||||
* https://github.com/captbaritone/webamp/blob/master/packages/webamp/CHANGELOG.md
|
||||
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
|
||||
* entrypoint which includes the Butterchurn library to enable the
|
||||
* Milkdrop visualizer.
|
||||
*/
|
||||
import Webamp from "https://unpkg.com/webamp@next";
|
||||
|
||||
/**
|
||||
* Butterchurn is not being actively maintained, but it is still works
|
||||
* great. Before it went into maintenance mode Jordan Berg (different
|
||||
* Jordan) cut a beta release of a version with the faster/more secure
|
||||
* eel->Wasm compiler that I wrote, so we use `butterchurn@3.0.0-beta.3`.
|
||||
*
|
||||
* Blog post about the eel->Wasm compiler here:
|
||||
* https://jordaneldredge.com/blog/speeding-up-winamps-music-visualizer-with-webassembly/
|
||||
*
|
||||
* This version is still using AMD modules, so it will write the export to
|
||||
* `window.butterchurn`. This is a pretty chunky files, so you way want to
|
||||
* find a way to lazy load it inside `importButterchurn` below.
|
||||
* Unfortunately, it's not an ES module, so I wasn't able to call
|
||||
* `import()` on it without some kind of bundler.
|
||||
*/
|
||||
import "https://unpkg.com/butterchurn@3.0.0-beta.3/dist/butterchurn.min.js";
|
||||
const butterchurn = window.butterchurn;
|
||||
|
||||
/**
|
||||
* This module, `butterchurn-presets@3.0.0-beta.4` contains a curated set
|
||||
* of awesome Butterchurn presets that have been packaged up to work with
|
||||
* the new compiler. This module is also packaged as an AMD module, so
|
||||
* when imported without a bundler it will write the export to `window`. I
|
||||
* think the package was never that thoughtfully built, so the export name
|
||||
* is, confusingly `window.base`. If that's a problem, using a bundler
|
||||
* might help.
|
||||
*
|
||||
* As audio plays, Webamp will randomly cycle through these presets with a
|
||||
* cool transition effect. You can also press "l" while the Milkdrop
|
||||
* window is open to open Milkdrop's preset selection menu.
|
||||
*/
|
||||
import "https://unpkg.com/butterchurn-presets@3.0.0-beta.4/dist/base.js";
|
||||
const butterchurnPresets = window.base.default;
|
||||
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
@ -63,32 +25,11 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
__butterchurnOptions: {
|
||||
importButterchurn: () => Promise.resolve(butterchurn),
|
||||
getPresets: () => {
|
||||
return Object.entries(butterchurnPresets).map(([name, preset]) => {
|
||||
return { name, butterchurnPresetObject: preset };
|
||||
});
|
||||
},
|
||||
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,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -9,7 +10,7 @@
|
|||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@next";
|
||||
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,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -9,14 +10,19 @@
|
|||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@next";
|
||||
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",
|
||||
|
|
@ -26,6 +32,9 @@
|
|||
name: "Mac OSX v1.5 (Aqua)",
|
||||
},
|
||||
],
|
||||
initialSkin: {
|
||||
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
|
||||
},
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -9,7 +10,7 @@
|
|||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@next";
|
||||
import Webamp from "https://unpkg.com/webamp@^2";
|
||||
const webamp = new Webamp({
|
||||
/**
|
||||
* Here we list three tracks. Note that the `metaData` fields and
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
|
|
@ -36,7 +37,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
|
||||
duration: 322.612245,
|
||||
},
|
||||
|
|
@ -47,7 +48,7 @@
|
|||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
|
||||
duration: 190.093061,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,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="./dist/main.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 --output-path dist --mode=development"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"webamp": "1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"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"
|
||||
|
|
|
|||
62079
package-lock.json
generated
62079
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -1,47 +1,52 @@
|
|||
{
|
||||
"name": "webamp-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples/*"
|
||||
],
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"overrides": {
|
||||
"graphql": "16.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
|
||||
"deploy": "sh deploy.sh",
|
||||
"test": "npx turbo test",
|
||||
"test:integration": "npx turbo run integration-tests",
|
||||
"test:all": "npx turbo run test integration-tests",
|
||||
"test:unit": "jest",
|
||||
"lint": "npx turbo lint",
|
||||
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check",
|
||||
"deploy": "npx turbo webamp#build webamp-modern#build --concurrency 1 && mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern",
|
||||
"format": "prettier --write '**/*.{js,ts,tsx}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@parcel/optimizer-data-url": "2.7.0",
|
||||
"@parcel/transformer-inline-string": "^2.8.2",
|
||||
"@swc/core": "^1.3.24",
|
||||
"@swc/jest": "^0.2.24",
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
||||
"@typescript-eslint/parser": "^8.36.0",
|
||||
"assert": "^2.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^27.5.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"prettier": "^2.3.2",
|
||||
"puppeteer": "^22.2.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5"
|
||||
},
|
||||
"jest": {
|
||||
"projects": [
|
||||
"config/jest.*.js"
|
||||
]
|
||||
},
|
||||
"dependencies": {},
|
||||
"version": "0.0.0-next-87012d8d"
|
||||
"version": "0.0.0-next-87012d8d",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ I wrote a blog post about this library which you can find [here](https://jordane
|
|||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
yarn add ani-cursor
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
npm install ani-cursor
|
||||
```
|
||||
|
|
@ -41,4 +35,4 @@ document.body.appendChild(h1);
|
|||
applyCursor("#pizza", "https://archive.org/cors/tucows_169906_Pizza_cursor/pizza.ani");
|
||||
```
|
||||
|
||||
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).
|
||||
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
};
|
||||
|
|
@ -2,14 +2,24 @@
|
|||
"name": "ani-cursor",
|
||||
"version": "0.0.5",
|
||||
"description": "Render .ani cursors as CSS animations in the browser",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"author": "Jordan Eldredge <jordan@jordaneldredge.com>",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/captbaritone/webamp.git",
|
||||
|
|
@ -21,6 +31,8 @@
|
|||
"homepage": "https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts,js",
|
||||
"test": "jest",
|
||||
"prepublish": "tsc"
|
||||
},
|
||||
|
|
@ -28,7 +40,9 @@
|
|||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.20.0",
|
||||
"typescript": "^5.3.3"
|
||||
"@swc/jest": "^0.2.24",
|
||||
"@types/node": "^24.0.10",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"byte-data": "18.1.1",
|
||||
|
|
@ -38,6 +52,13 @@
|
|||
"modulePathIgnorePatterns": [
|
||||
"dist"
|
||||
],
|
||||
"testEnvironment": "jsdom"
|
||||
"testEnvironment": "jsdom",
|
||||
"extensionsToTreatAsEsm": [".ts"],
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": ["@swc/jest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { convertAniBinaryToCSS } from "../";
|
||||
import { convertAniBinaryToCSS } from "../index.js";
|
||||
|
||||
const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseAni } from "./parser";
|
||||
import { parseAni } from "./parser.js";
|
||||
|
||||
type AniCursorImage = {
|
||||
frames: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||
"target": "ES2018" /* Specify ECMAScript target version */,
|
||||
"lib": ["ES2018", "dom"] /* Specify library files to be included in the compilation. */,
|
||||
"types": ["jest", "node"] /* Type declaration files to be included in compilation. */,
|
||||
"module": "ES2020" /* Specify module code generation */,
|
||||
"sourceMap": true /* Generates corresponding '.map' file. */,
|
||||
"outDir": "./dist" /* Redirect output structure to the directory. */,
|
||||
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
|
||||
|
|
@ -24,7 +26,9 @@
|
|||
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node" /* Use Node.js-style module resolution */,
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export */,
|
||||
|
||||
"declaration": true,
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# Archive.org Webamp Integration Test
|
||||
|
||||
This package contains an automated test to ensure that the [Webamp](https://webamp.org) integration on https://archive.org is still working correectly. It's run twice a day via a GitHub Action, which can be found [here](../../.github/workflows/ia-integration-tests.yml).
|
||||
## Run
|
||||
|
||||
```sh
|
||||
yarn
|
||||
node index.js
|
||||
```
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
const puppeteer = require("puppeteer");
|
||||
|
||||
// Handle a few different cases since specialized pages use specialized classes.
|
||||
const webampButtonSelector =
|
||||
".js-webamp-use_skin_for_audio_items, .webamp-link";
|
||||
|
||||
// Create our own log function so we can mute it if we want
|
||||
function log(message) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
const TIMEOUT = 10000;
|
||||
|
||||
async function expectSelector(page, selector) {
|
||||
log(`Waiting for selector ${selector}...`);
|
||||
await page.waitForSelector(selector, { visible: true, timeout: TIMEOUT });
|
||||
log(`Found selector ✅`);
|
||||
}
|
||||
|
||||
async function testPage({ url, name, firstTrackText }) {
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
log(`🏁 [${name}] ${url}`);
|
||||
await page.goto(url);
|
||||
await expectSelector(page, webampButtonSelector);
|
||||
log("Going to click the Webamp button");
|
||||
// For some reason `page.click` often fails with `Error: Node is either not
|
||||
// visible or not an HTMLElement` so we use `page.evaluate` instead.
|
||||
// https://stackoverflow.com/a/52336777
|
||||
// await page.click(webampButtonSelector, { timeout: TIMEOUT });
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.querySelector(".js-webamp-use_skin_for_audio_items, .webamp-link")
|
||||
.click();
|
||||
});
|
||||
await expectSelector(page, "#webamp #main-window");
|
||||
log("Looking for first track...");
|
||||
const firstTrack = await page.$(".track-cell.current");
|
||||
if (firstTrack == null) {
|
||||
throw new Error("Could not find first track");
|
||||
}
|
||||
log("Getting text of first track...");
|
||||
const actualFirstTrackText = await page.evaluate(
|
||||
(_) => _.textContent,
|
||||
firstTrack
|
||||
);
|
||||
|
||||
if (!actualFirstTrackText.includes(firstTrackText)) {
|
||||
throw new Error(
|
||||
`Could not find track title '${firstTrackText}' in "${actualFirstTrackText}"`
|
||||
);
|
||||
}
|
||||
|
||||
log("Clicking play...");
|
||||
await page.click("#webamp #main-window #play", { timeout: TIMEOUT });
|
||||
await expectSelector(page, "#webamp #main-window.play");
|
||||
log("✅ Success! Test passed.");
|
||||
} catch (e) {
|
||||
log(`🛑 Errored in [${name}]. Wrote screenshot to ./error.png`);
|
||||
await page.screenshot({ path: "error.png", fullPage: true });
|
||||
throw e;
|
||||
} finally {
|
||||
log("DONE");
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function testPageAndRetry(options) {
|
||||
let retries = 5;
|
||||
while (retries--) {
|
||||
try {
|
||||
await testPage(options);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (retries > 0) {
|
||||
console.warn(`Retrying... ${retries} retries left.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to pass even after 5 retries");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await testPageAndRetry({
|
||||
name: "Popular",
|
||||
url: "https://archive.org/details/gd73-06-10.sbd.hollister.174.sbeok.shnf",
|
||||
firstTrackText: "Grateful Dead - Morning Dew",
|
||||
});
|
||||
await testPageAndRetry({
|
||||
name: "Regular",
|
||||
url: "https://archive.org/details/78_mambo-no.-5_perez-prado-and-his-orchestra-d.-perez-prado_gbia0009774b",
|
||||
firstTrackText: "Mambo No. 5",
|
||||
});
|
||||
await testPageAndRetry({
|
||||
name: "Samples Only",
|
||||
url: "https://archive.org/details/lp_smokey-and-the-bandit-2-original-soundtrac_various-brenda-lee-burt-reynolds-don-willi",
|
||||
firstTrackText: "Texas Bound And Flyin",
|
||||
});
|
||||
await testPageAndRetry({
|
||||
name: "Stream Only",
|
||||
url: "https://archive.org/details/cd_a-sweeter-music_sarah-cahill",
|
||||
firstTrackText: "Be Kind to One Another",
|
||||
});
|
||||
await testPageAndRetry({
|
||||
name: "Another",
|
||||
url: "https://archive.org/details/78_house-of-the-rising-sun_josh-white-and-his-guitar_gbia0001628b",
|
||||
firstTrackText: "House Of The Rising Sun",
|
||||
});
|
||||
}
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Ensure process returns an error exit code so that other tools know the test failed.
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"name": "archive-org-integration-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Automated tests to ensure the Internet Archive's Webamp integration is working",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"prettier": {},
|
||||
"dependencies": {
|
||||
"puppeteer": "^3.0.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,28 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
node: true,
|
||||
es2021: true,
|
||||
jest: true,
|
||||
},
|
||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
// "no-console": "warn",
|
||||
// Disable rules that conflict with the project's style
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
// Override the base no-shadow rule since it conflicts with TypeScript
|
||||
"no-shadow": "off",
|
||||
// Relax rules for this project's existing style
|
||||
camelcase: "off",
|
||||
"dot-notation": "off",
|
||||
eqeqeq: "off",
|
||||
"no-undef-init": "off",
|
||||
"no-return-await": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-div-regex": "off",
|
||||
"guard-for-in": "off",
|
||||
"prefer-template": "off",
|
||||
"no-else-return": "off",
|
||||
"prefer-const": "off",
|
||||
"new-cap": "off",
|
||||
},
|
||||
ignorePatterns: ["dist/**"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class DiscordWinstonTransport extends Transport {
|
|||
let dataString = null;
|
||||
try {
|
||||
dataString = JSON.stringify(rest, null, 2);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
dataString = "COULD NOT STRINGIFY DATA";
|
||||
}
|
||||
await this._channel.send(`${message}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export const searchIndex = {
|
||||
export const client = {
|
||||
partialUpdateObjects: jest.fn(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ async function addModernSkinFromBuffer(
|
|||
): Promise<Result> {
|
||||
console.log("Write temporarty file.");
|
||||
const tempFile = temp.path({ suffix: ".wal" });
|
||||
fs.writeFileSync(tempFile, buffer);
|
||||
fs.writeFileSync(tempFile, new Uint8Array(buffer));
|
||||
console.log("Put skin to S3.");
|
||||
await S3.putSkin(md5, buffer, "wal");
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ async function addClassicSkinFromBuffer(
|
|||
uploader: string
|
||||
): Promise<Result> {
|
||||
const tempFile = temp.path({ suffix: ".wsz" });
|
||||
fs.writeFileSync(tempFile, buffer);
|
||||
fs.writeFileSync(tempFile, new Uint8Array(buffer));
|
||||
const tempScreenshotPath = temp.path({ suffix: ".png" });
|
||||
|
||||
const logLines: string[] = [];
|
||||
|
|
@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
|
|||
await setHashesForSkin(skin);
|
||||
|
||||
// Disable while we figure out our quota
|
||||
// await Skins.updateSearchIndex(ctx, md5);
|
||||
await Skins.updateSearchIndex(ctx, md5);
|
||||
|
||||
return { md5, status: "ADDED", skinType: "CLASSIC" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import algoliasearch from "algoliasearch";
|
||||
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY, ALGOLIA_INDEX } from "./config";
|
||||
import { algoliasearch } from "algoliasearch";
|
||||
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY } from "./config";
|
||||
|
||||
const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);
|
||||
export const searchIndex = client.initIndex(ALGOLIA_INDEX);
|
||||
export const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Query.fetch_skin_by_md5 (debug data) 1`] = `
|
||||
Object {
|
||||
"fetch_skin_by_md5": Object {
|
||||
"archive_files": Array [
|
||||
Object {
|
||||
{
|
||||
"fetch_skin_by_md5": {
|
||||
"archive_files": [
|
||||
{
|
||||
"date": "2000-05-08T07:44:52.000Z",
|
||||
"file_md5": "a_fake_file_md5",
|
||||
"filename": "a_fake_archive_file.bmp",
|
||||
"is_directory": false,
|
||||
"size": null,
|
||||
"skin": Object {
|
||||
"skin": {
|
||||
"md5": "a_fake_md5",
|
||||
},
|
||||
"text_content": null,
|
||||
|
|
@ -21,11 +21,11 @@ Object {
|
|||
"download_url": "https://r2.webampskins.org/skins/a_fake_md5.wsz",
|
||||
"filename": "path.wsz",
|
||||
"id": "Q2xhc3NpY1NraW5fX2FfZmFrZV9tZDU=",
|
||||
"internet_archive_item": Object {
|
||||
"internet_archive_item": {
|
||||
"identifier": "a_fake_ia_identifier",
|
||||
"metadata_url": "https://archive.org/metadata/a_fake_ia_identifier",
|
||||
"raw_metadata_json": null,
|
||||
"skin": Object {
|
||||
"skin": {
|
||||
"md5": "a_fake_md5",
|
||||
},
|
||||
"url": "https://archive.org/details/a_fake_ia_identifier",
|
||||
|
|
@ -34,10 +34,10 @@ Object {
|
|||
"museum_url": "https://skins.webamp.org/skin/a_fake_md5",
|
||||
"nsfw": false,
|
||||
"readme_text": null,
|
||||
"reviews": Array [],
|
||||
"reviews": [],
|
||||
"screenshot_url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
|
||||
"tweeted": false,
|
||||
"tweets": Array [],
|
||||
"tweets": [],
|
||||
"webamp_url": "https://webamp.org?skinUrl=https://r2.webampskins.org/skins/a_fake_md5.wsz",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import SkinModel from "../../data/SkinModel";
|
|||
import * as S3 from "../../s3";
|
||||
import { processUserUploads } from "../processUserUploads";
|
||||
import UserContext from "../../data/UserContext";
|
||||
import { searchIndex } from "../../algolia";
|
||||
import { client } from "../../algolia";
|
||||
import { createYogaInstance } from "../../app/graphql/yoga";
|
||||
import { YogaServerInstance } from "graphql-yoga";
|
||||
jest.mock("../../s3");
|
||||
|
|
@ -30,6 +30,10 @@ beforeEach(async () => {
|
|||
await knex.seed.run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
function gql(templateString: TemplateStringsArray): string {
|
||||
return templateString[0];
|
||||
}
|
||||
|
|
@ -123,35 +127,35 @@ describe("Query.skins", () => {
|
|||
`
|
||||
);
|
||||
expect(data.skins).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
{
|
||||
"count": 6,
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": [
|
||||
{
|
||||
"filename": "tweeted.wsz",
|
||||
"md5": "a_tweeted_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "Zelda_Amp_3.wsz",
|
||||
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "path.wsz",
|
||||
"md5": "a_fake_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "approved.wsz",
|
||||
"md5": "an_approved_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "rejected.wsz",
|
||||
"md5": "a_rejected_md5",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "nsfw.wsz",
|
||||
"md5": "a_nsfw_md5",
|
||||
"nsfw": true,
|
||||
|
|
@ -179,15 +183,15 @@ describe("Query.skins", () => {
|
|||
{ first: 2, offset: 1 }
|
||||
);
|
||||
expect(data.skins).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
{
|
||||
"count": 6,
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": [
|
||||
{
|
||||
"filename": "Zelda_Amp_3.wsz",
|
||||
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
|
||||
"nsfw": false,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"filename": "path.wsz",
|
||||
"md5": "a_fake_md5",
|
||||
"nsfw": false,
|
||||
|
|
@ -327,9 +331,10 @@ test("Mutation.mark_skin_nsfw", async () => {
|
|||
type: "MARKED_SKIN_NSFW",
|
||||
md5: "a_fake_md5",
|
||||
});
|
||||
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
|
||||
{ nsfw: true, objectID: "a_fake_md5" },
|
||||
]);
|
||||
expect(client.partialUpdateObjects).toHaveBeenCalledWith({
|
||||
indexName: "test-index",
|
||||
objects: [{ nsfw: true, objectID: "a_fake_md5" }],
|
||||
});
|
||||
expect(data).toEqual({ mark_skin_nsfw: true });
|
||||
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import { Router } from "express";
|
||||
import { createYoga, YogaInitialContext } from "graphql-yoga";
|
||||
|
||||
// import DEFAULT_QUERY from "./defaultQuery";
|
||||
import { getSchema } from "./schema";
|
||||
import UserContext from "../../data/UserContext.js";
|
||||
|
||||
/** @gqlContext */
|
||||
|
|
@ -12,18 +7,3 @@ export type Ctx = Express.Request;
|
|||
export function getUserContext(ctx: Ctx): UserContext {
|
||||
return ctx.ctx;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
const yoga = createYoga({
|
||||
schema: getSchema(),
|
||||
context: (ctx: YogaInitialContext) => {
|
||||
// @ts-expect-error
|
||||
return ctx.req;
|
||||
},
|
||||
});
|
||||
|
||||
// Bind GraphQL Yoga to the graphql endpoint to avoid rendering the playground on any path
|
||||
router.use("", yoga);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { Int } from "grats";
|
||||
import { knex } from "../../../db";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import ClassicSkinResolver from "./ClassicSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import { SkinRow } from "../../../types";
|
||||
|
||||
/**
|
||||
* Connection for bulk download skin metadata
|
||||
* @gqlType
|
||||
*/
|
||||
export class BulkDownloadConnection {
|
||||
_offset: number;
|
||||
_first: number;
|
||||
|
||||
constructor(first: number, offset: number) {
|
||||
this._first = first;
|
||||
this._offset = offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of skins available for download
|
||||
* @gqlField
|
||||
*/
|
||||
async totalCount(): Promise<Int> {
|
||||
// Get count of both classic and modern skins
|
||||
const [classicResult, modernResult] = await Promise.all([
|
||||
knex("skins").where({ skin_type: 1 }).count("* as count"),
|
||||
knex("skins").where({ skin_type: 2 }).count("* as count"),
|
||||
]);
|
||||
|
||||
const classicCount = Number(classicResult[0].count);
|
||||
const modernCount = Number(modernResult[0].count);
|
||||
|
||||
return classicCount + modernCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated total size in bytes (approximation for progress indication)
|
||||
* @gqlField
|
||||
*/
|
||||
async estimatedSizeBytes(): Promise<string> {
|
||||
const totalCount = await this.totalCount();
|
||||
// Rough estimate: average skin is ~56KB
|
||||
return (totalCount * 56 * 1024).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* List of skin metadata for bulk download
|
||||
* @gqlField
|
||||
*/
|
||||
async nodes(ctx: UserContext): Promise<Array<ISkin>> {
|
||||
// Get skins ordered by skin_type (classic first, then modern) and id for consistency
|
||||
const skins = await knex<SkinRow>("skins")
|
||||
.select(["id", "md5", "skin_type", "emails"])
|
||||
.orderBy([{ column: "skins.id", order: "asc" }])
|
||||
.where({ skin_type: 1 })
|
||||
.orWhere({ skin_type: 2 })
|
||||
.where((builder) => {
|
||||
builder.where({ skin_type: 1 }).orWhere({ skin_type: 2 });
|
||||
})
|
||||
.limit(this._first)
|
||||
.offset(this._offset);
|
||||
|
||||
return skins.map((skinRow) => {
|
||||
const skinModel = new SkinModel(ctx, skinRow);
|
||||
|
||||
if (skinRow.skin_type === 1) {
|
||||
return new ClassicSkinResolver(skinModel);
|
||||
} else if (skinRow.skin_type === 2) {
|
||||
return new ModernSkinResolver(skinModel);
|
||||
} else {
|
||||
throw new Error("Expected classic or modern skin");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for bulk downloading all skins in the museum
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export function bulkDownload({
|
||||
first = 1000,
|
||||
offset = 0,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
}): BulkDownloadConnection {
|
||||
if (first > 10000) {
|
||||
throw new Error("Maximum limit is 10000 for bulk download");
|
||||
}
|
||||
return new BulkDownloadConnection(first, offset);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ISkin } from "./CommonSkinResolver";
|
||||
import { NodeResolver, toId } from "./NodeResolver";
|
||||
import ReviewResolver from "./ReviewResolver";
|
||||
import path from "path";
|
||||
import { ID, Int } from "grats";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
|
||||
|
|
@ -24,11 +23,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
|||
return toId(this.__typename, this.md5());
|
||||
}
|
||||
async filename(normalize_extension?: boolean): Promise<string> {
|
||||
const filename = await this._model.getFileName();
|
||||
if (normalize_extension) {
|
||||
return path.parse(filename).name + ".wsz";
|
||||
}
|
||||
return filename;
|
||||
return await this._model.getFileName(normalize_extension);
|
||||
}
|
||||
|
||||
museum_url(): string {
|
||||
|
|
@ -46,7 +41,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
|||
nsfw(): Promise<boolean> {
|
||||
return this._model.getIsNsfw();
|
||||
}
|
||||
average_color(): string {
|
||||
average_color(): string | null {
|
||||
return this._model.getAverageColor();
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -69,19 +69,34 @@ export function id(skin: ISkin): ID {
|
|||
* has been uploaded under multiple names. Here we just pick one.
|
||||
* @gqlField
|
||||
*/
|
||||
export function filename(
|
||||
export async function filename(
|
||||
skin: ISkin,
|
||||
{
|
||||
normalize_extension = false,
|
||||
include_museum_id = false,
|
||||
}: {
|
||||
/**
|
||||
* If true, the the correct file extension (.wsz or .wal) will be .
|
||||
* Otherwise, the original user-uploaded file extension will be used.
|
||||
*/
|
||||
normalize_extension?: boolean;
|
||||
/**
|
||||
* If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
*/
|
||||
include_museum_id?: boolean;
|
||||
}
|
||||
): Promise<string> {
|
||||
return skin.filename(normalize_extension);
|
||||
const baseFilename = await skin.filename(normalize_extension);
|
||||
|
||||
if (!include_museum_id) {
|
||||
return baseFilename;
|
||||
}
|
||||
|
||||
const museumId = skin._model.getId();
|
||||
const segments = baseFilename.split(".");
|
||||
const fileExtension = segments.pop();
|
||||
|
||||
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Ctx } from "..";
|
||||
import UserContext from "../../../data/UserContext.js";
|
||||
import { Rating, ReviewRow } from "../../../types";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@ import UserContext from "../../../data/UserContext";
|
|||
import ClassicSkinResolver from "./ClassicSkinResolver";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import algoliasearch from "algoliasearch";
|
||||
import * as Skins from "../../../data/skins";
|
||||
import { knex } from "../../../db";
|
||||
|
||||
// These keys are already in the web client, so they are not secret at all.
|
||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||
const index = client.initIndex("Skins");
|
||||
|
||||
export default class SkinResolver {
|
||||
constructor() {
|
||||
throw new Error("This is a stub.");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Schema generated by Grats (https://grats.capt.dev)
|
||||
# Do not manually edit. Regenerate by running `npx grats`.
|
||||
|
||||
"""
|
||||
Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
|
||||
In all other cases, the position is non-null.
|
||||
|
|
@ -92,6 +93,10 @@ interface Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -161,7 +166,17 @@ type ArchiveFile {
|
|||
serverless Cloudflare function which tries to exctact the file on the fly.
|
||||
It may not work for all files.
|
||||
"""
|
||||
url: String @semanticNonNull
|
||||
url: String
|
||||
}
|
||||
|
||||
"""Connection for bulk download skin metadata"""
|
||||
type BulkDownloadConnection {
|
||||
"""Estimated total size in bytes (approximation for progress indication)"""
|
||||
estimatedSizeBytes: String @semanticNonNull
|
||||
"""List of skin metadata for bulk download"""
|
||||
nodes: [Skin!] @semanticNonNull
|
||||
"""Total number of skins available for download"""
|
||||
totalCount: Int @semanticNonNull
|
||||
}
|
||||
|
||||
"""A classic Winamp skin"""
|
||||
|
|
@ -177,6 +192,10 @@ type ClassicSkin implements Node & Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -307,6 +326,10 @@ type ModernSkin implements Node & Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -384,6 +407,8 @@ type Mutation {
|
|||
}
|
||||
|
||||
type Query {
|
||||
"""Get metadata for bulk downloading all skins in the museum"""
|
||||
bulkDownload(first: Int! = 1000, offset: Int! = 0): BulkDownloadConnection @semanticNonNull
|
||||
"""
|
||||
Fetch archive file by it's MD5 hash
|
||||
|
||||
|
|
@ -411,7 +436,7 @@ type Query {
|
|||
"""
|
||||
node(id: ID!): Node
|
||||
"""
|
||||
Search the database using the Algolia search index used by the Museum.
|
||||
Search the database using SQLite's FTS (full text search) index.
|
||||
|
||||
Useful for locating a particular skin.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
* Executable schema generated by Grats (https://grats.capt.dev)
|
||||
* Do not manually edit. Regenerate by running `npx grats`.
|
||||
*/
|
||||
import { defaultFieldResolver, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLBoolean, GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNonNull, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
|
||||
import { getUserContext as getUserContext } from "./index";
|
||||
|
||||
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
|
||||
import { getUserContext } from "./index";
|
||||
import { bulkDownload as queryBulkDownloadResolver } from "./resolvers/BulkDownloadConnection";
|
||||
import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./../../data/ArchiveFileModel";
|
||||
import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./../../data/IaItemModel";
|
||||
import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver, search_classic_skins as querySearch_classic_skinsResolver, search_skins as querySearch_skinsResolver, skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver";
|
||||
|
|
@ -26,6 +28,75 @@ async function assertNonNull<T>(value: T | Promise<T>): Promise<T> {
|
|||
return awaited;
|
||||
}
|
||||
export function getSchema(): GraphQLSchema {
|
||||
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "ArchiveFile",
|
||||
description: "A file found within a Winamp Skin's .wsz archive",
|
||||
fields() {
|
||||
return {
|
||||
date: {
|
||||
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
||||
name: "date",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsoDate());
|
||||
}
|
||||
},
|
||||
file_md5: {
|
||||
description: "The md5 hash of the file within the archive",
|
||||
name: "file_md5",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileMd5());
|
||||
}
|
||||
},
|
||||
filename: {
|
||||
description: "Filename of the file within the archive",
|
||||
name: "filename",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileName());
|
||||
}
|
||||
},
|
||||
is_directory: {
|
||||
description: "Is the file a directory?",
|
||||
name: "is_directory",
|
||||
type: GraphQLBoolean,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsDirectory());
|
||||
}
|
||||
},
|
||||
size: {
|
||||
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
|
||||
name: "size",
|
||||
type: GraphQLInt,
|
||||
resolve(source) {
|
||||
return source.getFileSize();
|
||||
}
|
||||
},
|
||||
skin: {
|
||||
description: "The skin in which this file was found",
|
||||
name: "skin",
|
||||
type: SkinType
|
||||
},
|
||||
text_content: {
|
||||
description: "The content of the file, if it's a text file",
|
||||
name: "text_content",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getTextContent();
|
||||
}
|
||||
},
|
||||
url: {
|
||||
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
|
||||
name: "url",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getUrl();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "InternetArchiveItem",
|
||||
fields() {
|
||||
|
|
@ -187,9 +258,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -253,70 +328,33 @@ export function getSchema(): GraphQLSchema {
|
|||
};
|
||||
}
|
||||
});
|
||||
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "ArchiveFile",
|
||||
description: "A file found within a Winamp Skin's .wsz archive",
|
||||
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "BulkDownloadConnection",
|
||||
description: "Connection for bulk download skin metadata",
|
||||
fields() {
|
||||
return {
|
||||
date: {
|
||||
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
||||
name: "date",
|
||||
estimatedSizeBytes: {
|
||||
description: "Estimated total size in bytes (approximation for progress indication)",
|
||||
name: "estimatedSizeBytes",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsoDate());
|
||||
resolve(source, args, context, info) {
|
||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||
}
|
||||
},
|
||||
file_md5: {
|
||||
description: "The md5 hash of the file within the archive",
|
||||
name: "file_md5",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileMd5());
|
||||
nodes: {
|
||||
description: "List of skin metadata for bulk download",
|
||||
name: "nodes",
|
||||
type: new GraphQLList(new GraphQLNonNull(SkinType)),
|
||||
resolve(source, _args, context) {
|
||||
return assertNonNull(source.nodes(getUserContext(context)));
|
||||
}
|
||||
},
|
||||
filename: {
|
||||
description: "Filename of the file within the archive",
|
||||
name: "filename",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileName());
|
||||
}
|
||||
},
|
||||
is_directory: {
|
||||
description: "Is the file a directory?",
|
||||
name: "is_directory",
|
||||
type: GraphQLBoolean,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsDirectory());
|
||||
}
|
||||
},
|
||||
size: {
|
||||
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
|
||||
name: "size",
|
||||
totalCount: {
|
||||
description: "Total number of skins available for download",
|
||||
name: "totalCount",
|
||||
type: GraphQLInt,
|
||||
resolve(source) {
|
||||
return source.getFileSize();
|
||||
}
|
||||
},
|
||||
skin: {
|
||||
description: "The skin in which this file was found",
|
||||
name: "skin",
|
||||
type: SkinType
|
||||
},
|
||||
text_content: {
|
||||
description: "The content of the file, if it's a text file",
|
||||
name: "text_content",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getTextContent();
|
||||
}
|
||||
},
|
||||
url: {
|
||||
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
|
||||
name: "url",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getUrl());
|
||||
resolve(source, args, context, info) {
|
||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -382,9 +420,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -545,9 +587,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -903,13 +949,30 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "Query",
|
||||
fields() {
|
||||
return {
|
||||
bulkDownload: {
|
||||
description: "Get metadata for bulk downloading all skins in the museum",
|
||||
name: "bulkDownload",
|
||||
type: BulkDownloadConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 1000
|
||||
},
|
||||
offset: {
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
resolve(_source, args) {
|
||||
return assertNonNull(queryBulkDownloadResolver(args));
|
||||
}
|
||||
},
|
||||
fetch_archive_file_by_md5: {
|
||||
description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.",
|
||||
name: "fetch_archive_file_by_md5",
|
||||
type: ArchiveFileType,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -923,7 +986,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: InternetArchiveItemType,
|
||||
args: {
|
||||
identifier: {
|
||||
name: "identifier",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -937,7 +999,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: SkinType,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -951,7 +1012,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: TweetType,
|
||||
args: {
|
||||
url: {
|
||||
name: "url",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -973,12 +1033,10 @@ export function getSchema(): GraphQLSchema {
|
|||
type: ModernSkinsConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
}
|
||||
|
|
@ -993,7 +1051,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: NodeType,
|
||||
args: {
|
||||
id: {
|
||||
name: "id",
|
||||
type: new GraphQLNonNull(GraphQLID)
|
||||
}
|
||||
},
|
||||
|
|
@ -1002,22 +1059,19 @@ export function getSchema(): GraphQLSchema {
|
|||
}
|
||||
},
|
||||
search_classic_skins: {
|
||||
description: "Search the database using the Algolia search index used by the Museum.\n\nUseful for locating a particular skin.",
|
||||
description: "Search the database using SQLite's FTS (full text search) index.\n\nUseful for locating a particular skin.",
|
||||
name: "search_classic_skins",
|
||||
type: new GraphQLList(ClassicSkinType),
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
query: {
|
||||
name: "query",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1031,17 +1085,14 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinType),
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
query: {
|
||||
name: "query",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1063,21 +1114,17 @@ export function getSchema(): GraphQLSchema {
|
|||
type: SkinsConnectionType,
|
||||
args: {
|
||||
filter: {
|
||||
name: "filter",
|
||||
type: SkinsFilterOptionType
|
||||
},
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
sort: {
|
||||
name: "sort",
|
||||
type: SkinsSortOptionType
|
||||
}
|
||||
},
|
||||
|
|
@ -1099,17 +1146,14 @@ export function getSchema(): GraphQLSchema {
|
|||
type: TweetsConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
sort: {
|
||||
name: "sort",
|
||||
type: TweetsSortOptionType
|
||||
}
|
||||
},
|
||||
|
|
@ -1123,7 +1167,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinUploadType),
|
||||
args: {
|
||||
ids: {
|
||||
name: "ids",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1138,7 +1181,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinUploadType),
|
||||
args: {
|
||||
md5s: {
|
||||
name: "md5s",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1205,7 +1247,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(UploadUrlType),
|
||||
args: {
|
||||
files: {
|
||||
name: "files",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1219,11 +1260,9 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
id: {
|
||||
name: "id",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
},
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1244,7 +1283,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1258,7 +1296,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1272,7 +1309,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1286,7 +1322,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1300,15 +1335,12 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
email: {
|
||||
name: "email",
|
||||
type: GraphQLString
|
||||
},
|
||||
message: {
|
||||
name: "message",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
},
|
||||
url: {
|
||||
name: "url",
|
||||
type: GraphQLString
|
||||
}
|
||||
},
|
||||
|
|
@ -1328,8 +1360,19 @@ export function getSchema(): GraphQLSchema {
|
|||
}
|
||||
});
|
||||
return new GraphQLSchema({
|
||||
directives: [...specifiedDirectives, new GraphQLDirective({
|
||||
name: "semanticNonNull",
|
||||
locations: [DirectiveLocation.FIELD_DEFINITION],
|
||||
description: "Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.\nIn all other cases, the position is non-null.\n\nTools doing code generation may use this information to generate the position as non-null if field errors are handled out of band:\n\n```graphql\ntype User {\n # email is semantically non-null and can be generated as non-null by error-handling clients.\n email: String @semanticNonNull\n}\n```\n\nThe `levels` argument indicates what levels are semantically non null in case of lists:\n\n```graphql\ntype User {\n # friends is semantically non null\n friends: [User] @semanticNonNull # same as @semanticNonNull(levels: [0])\n\n # every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [1])\n\n # friends as well as every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [0, 1])\n}\n```\n\n`levels` are zero indexed.\nPassing a negative level or a level greater than the list dimension is an error.",
|
||||
args: {
|
||||
levels: {
|
||||
type: new GraphQLList(GraphQLInt),
|
||||
defaultValue: [0]
|
||||
}
|
||||
}
|
||||
})],
|
||||
query: QueryType,
|
||||
mutation: MutationType,
|
||||
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
|
||||
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, BulkDownloadConnectionType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60;
|
|||
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject("timeout"), duration)
|
||||
),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject("timeout"), duration);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from "../App";
|
||||
|
||||
export default function Layout() {
|
||||
return <App />;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import App from "../../../App";
|
||||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "../skinMetadata";
|
||||
|
||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|||
}
|
||||
|
||||
export default function Page() {
|
||||
return <App />;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import App from "../../App";
|
||||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "./skinMetadata";
|
||||
|
||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|||
}
|
||||
|
||||
export default function Page() {
|
||||
return <App />;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Metadata } from "next";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import SkinModel from "../../../../data/SkinModel";
|
||||
import UserContext from "../../../../data/UserContext";
|
||||
|
||||
export async function generateSkinPageMetadata(
|
||||
hash: string
|
||||
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Smartphone,
|
||||
Info,
|
||||
Grid3x3,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
import {
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
unstable_ViewTransition as ViewTransition,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
export default function BottomMenuBar() {
|
||||
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const toggleHamburger = () => {
|
||||
setIsHamburgerOpen(!isHamburgerOpen);
|
||||
};
|
||||
|
||||
// Close hamburger menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
isHamburgerOpen
|
||||
) {
|
||||
setIsHamburgerOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHamburgerOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isHamburgerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hamburger Menu Overlay */}
|
||||
{isHamburgerOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "4.5rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.98)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderBottom: "none",
|
||||
boxShadow: "0 -4px 12px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<div ref={menuRef}>
|
||||
<HamburgerMenuItem
|
||||
href="/scroll/about"
|
||||
icon={<Info size={20} />}
|
||||
label="About"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="/scroll/feedback"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="Feedback"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="https://github.com/captbaritone/webamp/"
|
||||
icon={<Github size={20} />}
|
||||
label="GitHub"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Menu Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.95)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
padding: "0.75rem 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
|
||||
display: "flex",
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MenuButton
|
||||
href="/scroll"
|
||||
icon={<Grid3x3 size={24} />}
|
||||
label="Grid"
|
||||
isActive={pathname === "/scroll"}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/scroll/skin"
|
||||
icon={<Smartphone size={24} />}
|
||||
label="Feed"
|
||||
isActive={pathname.startsWith("/scroll/skin")}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/upload"
|
||||
icon={<Upload size={24} />}
|
||||
label="Upload"
|
||||
isActive={pathname === "/upload"}
|
||||
/>
|
||||
<MenuButton
|
||||
icon={<Menu size={24} />}
|
||||
label="Menu"
|
||||
onClick={toggleHamburger}
|
||||
isButton
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuButtonProps = {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isButton?: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function MenuButton({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
isButton = false,
|
||||
isActive = false,
|
||||
onClick,
|
||||
}: MenuButtonProps) {
|
||||
const touchTargetSize = "3.0rem";
|
||||
|
||||
const containerStyle = {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.25rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "color 0.2s ease",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
position: "relative" as const,
|
||||
width: touchTargetSize,
|
||||
minWidth: touchTargetSize,
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#fff";
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Active indicator line */}
|
||||
{isActive && (
|
||||
<ViewTransition name="footer-menu-active">
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-0.75rem",
|
||||
left: 0,
|
||||
width: touchTargetSize,
|
||||
height: "1px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</ViewTransition>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
></span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isButton) {
|
||||
return (
|
||||
<button
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
title={label}
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type HamburgerMenuItemProps = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
function HamburgerMenuItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
external = false,
|
||||
}: HamburgerMenuItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
padding: "1rem 1.5rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s ease, color 0.2s ease",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use server";
|
||||
|
||||
import { knex } from "../../../db";
|
||||
import { markAsNSFW } from "../../../data/skins";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
|
||||
export async function logUserEvent(sessionId: string, event: UserEvent) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
console.log("Logging user event:", {
|
||||
sessionId,
|
||||
timestamp,
|
||||
event,
|
||||
});
|
||||
|
||||
await knex("user_log_events").insert({
|
||||
session_id: sessionId,
|
||||
timestamp: timestamp,
|
||||
metadata: JSON.stringify(event),
|
||||
});
|
||||
|
||||
// If this is a NSFW report, call the existing infrastructure
|
||||
if (event.type === "skin_flag_nsfw") {
|
||||
// Create an anonymous user context for the report
|
||||
const ctx = new UserContext();
|
||||
await markAsNSFW(ctx, event.skinMd5);
|
||||
}
|
||||
}
|
||||
|
||||
export type UserEvent =
|
||||
| {
|
||||
type: "session_start";
|
||||
}
|
||||
| {
|
||||
type: "session_end";
|
||||
reason: "unmount" | "before_unload";
|
||||
}
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
| {
|
||||
type: "skin_view";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_view_start";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_view_end";
|
||||
skinMd5: string;
|
||||
durationMs: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_start";
|
||||
offset: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_success";
|
||||
offset: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_failure";
|
||||
offset: number;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: "readme_expand";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_download";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_like";
|
||||
skinMd5: string;
|
||||
liked: boolean;
|
||||
}
|
||||
| {
|
||||
type: "skin_flag_nsfw";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_open";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_success";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_failure";
|
||||
skinMd5: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: "menu_click";
|
||||
menuItem: string;
|
||||
}
|
||||
| {
|
||||
type: "scroll_hint_shown";
|
||||
};
|
||||
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal file
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
unstable_ViewTransition as ViewTransition,
|
||||
useEffect,
|
||||
} from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
import { useWindowSize } from "../../../legacy-client/src/hooks";
|
||||
import {
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
MOBILE_MAX_WIDTH,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
|
||||
import { searchSkins as performAlgoliaSearch } from "./algoliaClient";
|
||||
|
||||
// Simple utility to get screenshot URL (avoiding server-side import)
|
||||
function getScreenshotUrl(md5: string): string {
|
||||
return `https://r2.webampskins.org/screenshots/${md5}.png`;
|
||||
}
|
||||
|
||||
type CellData = {
|
||||
skins: GridSkin[];
|
||||
columnCount: number;
|
||||
width: number;
|
||||
height: number;
|
||||
loadMoreSkins: (startIndex: number) => Promise<void>;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
data,
|
||||
}: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
data: CellData;
|
||||
}) {
|
||||
const { skins, width, height, columnCount } = data;
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
data.loadMoreSkins(index);
|
||||
const skin = skins[index];
|
||||
|
||||
if (!skin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={{ width, height, position: "relative" }}>
|
||||
<Link href={`/scroll/skin/${skin.md5}`}>
|
||||
<ViewTransition name={`skin-${skin.md5}`}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</ViewTransition>
|
||||
</Link>
|
||||
{skin.nsfw && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
NSFW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SkinTableProps = {
|
||||
initialSkins: GridSkin[];
|
||||
initialTotal: number;
|
||||
};
|
||||
|
||||
export default function SkinTable({
|
||||
initialSkins,
|
||||
initialTotal,
|
||||
}: SkinTableProps) {
|
||||
const { windowWidth, windowHeight } = useWindowSize();
|
||||
|
||||
// Search input state - separate input value from actual search query
|
||||
const [inputValue, setInputValue] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("q") || "";
|
||||
});
|
||||
|
||||
// State for browsing mode
|
||||
const [browseSkins, setBrowseSkins] = useState<GridSkin[]>(initialSkins);
|
||||
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([0]));
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// State for search mode
|
||||
const [searchSkins, setSearchSkins] = useState<GridSkin[]>([]);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [_, setSearchIsPending] = useState(false);
|
||||
|
||||
// Debounce timer ref
|
||||
|
||||
// Determine which mode we're in based on actual search query, not input
|
||||
const isSearchMode = inputValue.trim().length > 0;
|
||||
const skins = isSearchMode ? searchSkins : browseSkins;
|
||||
const total = isSearchMode ? searchSkins.length : initialTotal;
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setInputValue(query);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const query = inputValue;
|
||||
const newUrl = query.trim() === "" ? "/scroll/" : `/scroll/?q=${query}`;
|
||||
// window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
|
||||
// If query is empty, clear results immediately
|
||||
if (!query || query.trim().length === 0) {
|
||||
setSearchSkins([]);
|
||||
setSearchError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchResults() {
|
||||
try {
|
||||
setSearchIsPending(true);
|
||||
const result = await performAlgoliaSearch(query);
|
||||
const hits = result.hits as Array<{
|
||||
objectID: string;
|
||||
fileName: string;
|
||||
nsfw?: boolean;
|
||||
}>;
|
||||
const searchResults: GridSkin[] = hits.map((hit) => ({
|
||||
md5: hit.objectID,
|
||||
screenshotUrl: getScreenshotUrl(hit.objectID),
|
||||
fileName: hit.fileName,
|
||||
nsfw: hit.nsfw ?? false,
|
||||
}));
|
||||
setSearchSkins(searchResults);
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
setSearchError("Search failed. Please try again.");
|
||||
setSearchSkins([]);
|
||||
} finally {
|
||||
setSearchIsPending(false);
|
||||
}
|
||||
}
|
||||
fetchResults();
|
||||
}, [inputValue]);
|
||||
|
||||
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9));
|
||||
const columnWidth = windowWidth / columnCount;
|
||||
const rowHeight = columnWidth * SKIN_RATIO;
|
||||
const pageSize = 50; // Number of skins to load per page
|
||||
|
||||
const loadMoreSkins = useCallback(
|
||||
async (startIndex: number) => {
|
||||
// Don't load more in search mode
|
||||
if (isSearchMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = Math.floor(startIndex / pageSize);
|
||||
|
||||
// Don't reload if we already have this page
|
||||
if (loadedPages.has(pageNumber) || isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingRef.current = true;
|
||||
try {
|
||||
const offset = pageNumber * pageSize;
|
||||
const newSkins = await getMuseumPageSkins(offset, pageSize);
|
||||
setBrowseSkins((prev) => [...prev, ...newSkins]);
|
||||
setLoadedPages((prev) => new Set([...prev, pageNumber]));
|
||||
} catch (error) {
|
||||
console.error("Failed to load skins:", error);
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[loadedPages, pageSize, isSearchMode]
|
||||
);
|
||||
|
||||
const itemKey = useCallback(
|
||||
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const skin = skins[index];
|
||||
return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`;
|
||||
},
|
||||
[columnCount, skins]
|
||||
);
|
||||
|
||||
const gridRef = React.useRef<any>(null);
|
||||
const itemRef = React.useRef<number>(0);
|
||||
|
||||
const onScroll = useMemo(() => {
|
||||
const half = Math.round(columnCount / 2);
|
||||
return (scrollData: { scrollTop: number }) => {
|
||||
itemRef.current =
|
||||
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
|
||||
};
|
||||
}, [columnCount, rowHeight]);
|
||||
|
||||
const itemData: CellData = useMemo(
|
||||
() => ({
|
||||
skins,
|
||||
columnCount,
|
||||
width: columnWidth,
|
||||
height: rowHeight,
|
||||
loadMoreSkins,
|
||||
}),
|
||||
[skins, columnCount, columnWidth, rowHeight, loadMoreSkins]
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="infinite-skins">
|
||||
{/* Floating Search Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "4.25rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "calc(100% - 2rem)",
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
padding: "0 1rem",
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search skins..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
paddingRight: "1rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.55)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "9999px",
|
||||
color: "#fff",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
transition: "padding-right 0.2s ease",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
|
||||
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
|
||||
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{isSearchMode && searchError && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
color: "#ff6b6b",
|
||||
}}
|
||||
>
|
||||
{searchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty Results */}
|
||||
{isSearchMode && !searchError && skins.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
No results found for "{inputValue}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid - show when browsing or when we have results (even while pending) */}
|
||||
{(!isSearchMode || (!searchError && skins.length > 0)) && (
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
itemKey={itemKey}
|
||||
itemData={itemData}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={windowHeight}
|
||||
rowCount={Math.ceil(total / columnCount)}
|
||||
rowHeight={rowHeight}
|
||||
width={windowWidth}
|
||||
overscanRowsCount={5}
|
||||
onScroll={onScroll}
|
||||
style={{ overflowY: "scroll" }}
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
memo,
|
||||
} from "react";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import {
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
|
||||
type Props = {
|
||||
initialSkins: ClientSkin[];
|
||||
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type CellProps = {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
data: {
|
||||
skins: ClientSkin[];
|
||||
columnCount: number;
|
||||
requestSkinsIfNeeded: (index: number) => void;
|
||||
};
|
||||
};
|
||||
|
||||
// Extract Cell as a separate component so we can use hooks
|
||||
const GridCell = memo(({ columnIndex, rowIndex, style, data }: CellProps) => {
|
||||
const { skins, columnCount, requestSkinsIfNeeded } = data;
|
||||
const router = useRouter();
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const skin = skins[index];
|
||||
|
||||
// Request more skins if this cell needs data
|
||||
useEffect(() => {
|
||||
if (!skin) {
|
||||
requestSkinsIfNeeded(index);
|
||||
}
|
||||
}, [skin, index, requestSkinsIfNeeded]);
|
||||
|
||||
if (!skin) {
|
||||
return <div style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: "2px",
|
||||
boxSizing: "border-box",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
router.push(`/scroll/skin/${skin.md5}`);
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
imageRendering: "pixelated",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GridCell.displayName = "GridCell";
|
||||
|
||||
// Calculate grid dimensions based on window width
|
||||
// Skins will be scaled to fill horizontally across multiple columns
|
||||
function getGridDimensions(windowWidth: number) {
|
||||
const scale = 1.0; // Can be adjusted for different sizes
|
||||
const columnCount = Math.max(
|
||||
1,
|
||||
Math.floor(windowWidth / (SCREENSHOT_WIDTH * scale))
|
||||
);
|
||||
const columnWidth = windowWidth / columnCount;
|
||||
const rowHeight = columnWidth * SKIN_RATIO;
|
||||
return { columnWidth, rowHeight, columnCount };
|
||||
}
|
||||
|
||||
export default function InfiniteScrollGrid({
|
||||
initialSkins,
|
||||
getSkins,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(0);
|
||||
const [windowHeight, setWindowHeight] = useState(0);
|
||||
const gridRef = useRef<Grid>(null);
|
||||
const requestedIndicesRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Track window size
|
||||
useLayoutEffect(() => {
|
||||
function updateSize() {
|
||||
setWindowWidth(window.innerWidth);
|
||||
setWindowHeight(window.innerHeight);
|
||||
}
|
||||
updateSize();
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
// Scroll to top when window width changes (column count changes)
|
||||
useEffect(() => {
|
||||
if (gridRef.current && windowWidth > 0) {
|
||||
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
|
||||
}
|
||||
}, [windowWidth]);
|
||||
|
||||
// Function to request more skins when a cell needs data
|
||||
const requestSkinsIfNeeded = useCallback(
|
||||
(index: number) => {
|
||||
// Only fetch if this index is beyond our current data
|
||||
if (index >= skins.length) {
|
||||
// Calculate which batch this index belongs to
|
||||
const batchSize = 50; // Fetch in batches
|
||||
const batchStart = Math.floor(skins.length / batchSize) * batchSize;
|
||||
|
||||
// Only fetch if we haven't already requested this batch
|
||||
if (!requestedIndicesRef.current.has(batchStart) && !fetching) {
|
||||
requestedIndicesRef.current.add(batchStart);
|
||||
setFetching(true);
|
||||
getSkins(sessionId, batchStart)
|
||||
.then((newSkins) => {
|
||||
setSkins((prevSkins) => [...prevSkins, ...newSkins]);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch(() => {
|
||||
requestedIndicesRef.current.delete(batchStart);
|
||||
setFetching(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[skins.length, fetching, sessionId, getSkins]
|
||||
);
|
||||
|
||||
const { columnWidth, rowHeight, columnCount } =
|
||||
getGridDimensions(windowWidth);
|
||||
|
||||
if (windowWidth === 0 || windowHeight === 0) {
|
||||
return null; // Don't render until we have window dimensions
|
||||
}
|
||||
|
||||
const rowCount = Math.ceil(skins.length / columnCount);
|
||||
|
||||
const itemData = {
|
||||
skins,
|
||||
columnCount,
|
||||
requestSkinsIfNeeded,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: "#1a1a1a" }}>
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={windowHeight}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={windowWidth}
|
||||
itemData={itemData}
|
||||
overscanRowCount={2}
|
||||
style={{
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{GridCell}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ReactNode } from "react";
|
||||
import { Heart, Share2, Flag, Download } from "lucide-react";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import { logUserEvent } from "./Events";
|
||||
|
||||
type Props = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function SkinActionIcons({ skin, sessionId }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "1rem",
|
||||
bottom: "2rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
paddingBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<LikeButton skin={skin} sessionId={sessionId} />
|
||||
<ShareButton skin={skin} sessionId={sessionId} />
|
||||
<FlagButton skin={skin} sessionId={sessionId} />
|
||||
<DownloadButton skin={skin} sessionId={sessionId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Implementation details below
|
||||
|
||||
type ButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
opacity?: number;
|
||||
"aria-label": string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Button({
|
||||
onClick,
|
||||
disabled = false,
|
||||
opacity = 1,
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
opacity,
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LikeButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function LikeButton({ skin, sessionId }: LikeButtonProps) {
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(skin.likeCount);
|
||||
|
||||
const handleLike = async () => {
|
||||
const newLikedState = !isLiked;
|
||||
setIsLiked(newLikedState);
|
||||
|
||||
// Optimistically update the like count
|
||||
setLikeCount((prevCount) =>
|
||||
newLikedState ? prevCount + 1 : prevCount - 1
|
||||
);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_like",
|
||||
skinMd5: skin.md5,
|
||||
liked: newLikedState,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleLike} aria-label="Like">
|
||||
<Heart
|
||||
size={32}
|
||||
color="white"
|
||||
fill={isLiked ? "white" : "none"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{likeCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{likeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type ShareButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function ShareButton({ skin, sessionId }: ShareButtonProps) {
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_open",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
|
||||
await navigator.share({
|
||||
title: skin.fileName,
|
||||
text: `Check out this Winamp skin: ${skin.fileName}`,
|
||||
url: skin.shareUrl,
|
||||
});
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_success",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled or share failed
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
console.error("Share failed:", error);
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_failure",
|
||||
skinMd5: skin.md5,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(skin.shareUrl);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_success",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
alert("Share link copied to clipboard!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleShare} aria-label="Share">
|
||||
<Share2 size={32} color="white" strokeWidth={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type FlagButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function FlagButton({ skin, sessionId }: FlagButtonProps) {
|
||||
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
|
||||
|
||||
const handleFlagNsfw = async () => {
|
||||
if (isFlagged) return; // Only allow flagging once
|
||||
|
||||
setIsFlagged(true);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_flag_nsfw",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleFlagNsfw}
|
||||
disabled={isFlagged}
|
||||
opacity={isFlagged ? 0.5 : 1}
|
||||
aria-label="Flag as NSFW"
|
||||
>
|
||||
<Flag
|
||||
size={32}
|
||||
color="white"
|
||||
fill={isFlagged ? "white" : "none"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type DownloadButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
|
||||
const handleDownload = async () => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_download",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
|
||||
// Trigger download
|
||||
window.location.href = skin.downloadUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleDownload} aria-label="Download">
|
||||
<Download size={32} color="white" strokeWidth={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
import { unstable_ViewTransition as ViewTransition } from "react";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import SkinActionIcons from "./SkinActionIcons";
|
||||
import WebampComponent from "./Webamp";
|
||||
|
||||
type Props = {
|
||||
skin: ClientSkin;
|
||||
index: number;
|
||||
sessionId: string;
|
||||
focused: boolean;
|
||||
};
|
||||
|
||||
export default function SkinPage({ skin, index, sessionId, focused }: Props) {
|
||||
const showWebamp = focused;
|
||||
return (
|
||||
<div
|
||||
key={skin.md5}
|
||||
skin-md5={skin.md5}
|
||||
skin-index={index}
|
||||
className="scroller"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
position: "relative",
|
||||
paddingTop: "2rem", // Space for top shadow
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<ViewTransition name={`skin-${skin.md5}`}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "275 / 348",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
{showWebamp && (
|
||||
<WebampComponent
|
||||
skinUrl={skin.skinUrl}
|
||||
closeModal={() => {}}
|
||||
loaded={() => {}}
|
||||
/>
|
||||
)}
|
||||
</ViewTransition>
|
||||
|
||||
<SkinActionIcons skin={skin} sessionId={sessionId} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
paddingLeft: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
fontSize: "0.9rem",
|
||||
paddingBottom: "0",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
color: "#ccc",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{skin.fileName}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
paddingTop: "0",
|
||||
color: "#999",
|
||||
fontFamily: 'monospace, "Courier New", Courier, monospace',
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{skin.readmeStart}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal file
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useLayoutEffect, useEffect } from "react";
|
||||
import SkinPage from "./SkinPage";
|
||||
import { logUserEvent } from "./Events";
|
||||
import { useScrollHint } from "./useScrollHint";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
|
||||
export type ClientSkin = {
|
||||
screenshotUrl: string;
|
||||
skinUrl: string;
|
||||
fileName: string;
|
||||
md5: string;
|
||||
readmeStart: string;
|
||||
downloadUrl: string;
|
||||
shareUrl: string;
|
||||
nsfw: boolean;
|
||||
likeCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialSkins: ClientSkin[];
|
||||
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function SkinScroller({
|
||||
initialSkins,
|
||||
getSkins,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
|
||||
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
|
||||
const [hasEverScrolled, setHasEverScrolled] = useState(false);
|
||||
|
||||
// Track if user has ever scrolled to another skin
|
||||
useEffect(() => {
|
||||
if (visibleSkinIndex > 0) {
|
||||
setHasEverScrolled(true);
|
||||
}
|
||||
}, [visibleSkinIndex]);
|
||||
|
||||
// Show scroll hint only if user has never scrolled to another skin
|
||||
useScrollHint({
|
||||
containerRef,
|
||||
enabled: visibleSkinIndex === 0 && !hasEverScrolled,
|
||||
onHintShown: () => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "scroll_hint_shown",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use IntersectionObserver for cross-browser compatibility (iOS doesn't support scrollsnapchange)
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
// When an element becomes mostly visible (> 50% intersecting)
|
||||
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
|
||||
const index = parseInt(
|
||||
entry.target.getAttribute("skin-index") || "0",
|
||||
10
|
||||
);
|
||||
setVisibleSkinIndex(index);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: containerRef,
|
||||
threshold: 0.5, // Trigger when 50% of the element is visible
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all skin page elements
|
||||
const skinElements = containerRef.querySelectorAll("[skin-index]");
|
||||
skinElements.forEach((element) => observer.observe(element));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [containerRef, skins.length]);
|
||||
|
||||
useEffect(() => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_start",
|
||||
});
|
||||
|
||||
function beforeUnload() {
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_end",
|
||||
reason: "before_unload",
|
||||
});
|
||||
}
|
||||
|
||||
addEventListener("beforeunload", beforeUnload);
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnload);
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_end",
|
||||
reason: "unmount",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// We want the URL and title to update as you scroll, but
|
||||
// we can't trigger a NextJS navigation since that would remount the
|
||||
// component. So, here we replicate the metadata behavior of the route.
|
||||
const skinMd5 = skins[visibleSkinIndex].md5;
|
||||
const newUrl = `/scroll/skin/${skinMd5}`;
|
||||
window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_view_start",
|
||||
skinMd5,
|
||||
});
|
||||
const startTime = Date.now();
|
||||
return () => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_view_end",
|
||||
skinMd5: skins[visibleSkinIndex].md5,
|
||||
durationMs,
|
||||
});
|
||||
};
|
||||
}, [visibleSkinIndex, skins, fetching]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (fetching) {
|
||||
return;
|
||||
}
|
||||
if (visibleSkinIndex + 5 >= skins.length) {
|
||||
setFetching(true);
|
||||
console.log("Fetching more skins...");
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_start",
|
||||
offset: skins.length,
|
||||
});
|
||||
getSkins(sessionId, skins.length)
|
||||
.then((newSkins) => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_success",
|
||||
offset: skins.length,
|
||||
});
|
||||
setSkins([...skins, ...newSkins]);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_failure",
|
||||
offset: skins.length,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
setFetching(false);
|
||||
});
|
||||
}
|
||||
}, [visibleSkinIndex, skins, fetching]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
style={{
|
||||
maxWidth: MOBILE_MAX_WIDTH, // 9:16 aspect ratio for scroll, full width for grid
|
||||
margin: "0 auto",
|
||||
height: "100vh",
|
||||
// width: "100%",
|
||||
overflowY: "scroll",
|
||||
scrollSnapType: "y mandatory",
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{skins.map((skin, i) => {
|
||||
return (
|
||||
<SkinPage
|
||||
key={skin.md5}
|
||||
skin={skin}
|
||||
index={i}
|
||||
focused={i === visibleSkinIndex}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Top shadow overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "4rem",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(26, 26, 26, 0.8) 0%, rgba(26, 26, 26, 0.4) 50%, rgba(26, 26, 26, 0) 100%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 500,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal file
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { ReactNode, CSSProperties } from "react";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
|
||||
type StaticPageProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function StaticPage({ children }: StaticPageProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#1a1a1a",
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
margin: "0 auto",
|
||||
padding: "2rem 1.5rem",
|
||||
color: "#fff",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled heading components
|
||||
export function Heading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
marginBottom: "1.5rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
export function Subheading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
marginTop: "2rem",
|
||||
marginBottom: "1rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled link component
|
||||
export function Link({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
style={{
|
||||
color: "#6b9eff",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled paragraph component
|
||||
export function Paragraph({ children }: { children: ReactNode }) {
|
||||
return <p style={{ marginBottom: "1rem" }}>{children}</p>;
|
||||
}
|
||||
|
||||
// Styled form components
|
||||
export function Label({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.5rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
style,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<input
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "4px",
|
||||
color: "#fff",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
style,
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "4px",
|
||||
color: "#fff",
|
||||
fontFamily: "inherit",
|
||||
display: "block",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
style,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<button
|
||||
style={{
|
||||
padding: "0.75rem 2rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "#6b9eff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
...style,
|
||||
}}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal file
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
SCREENSHOT_HEIGHT,
|
||||
SCREENSHOT_WIDTH,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
|
||||
type Props = {
|
||||
skinUrl: string;
|
||||
closeModal: () => void;
|
||||
loaded: () => void;
|
||||
};
|
||||
|
||||
export default function WebampComponent({
|
||||
skinUrl,
|
||||
closeModal,
|
||||
loaded,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const outerRef = useRef<HTMLDivElement | null>(null);
|
||||
// @ts-ignore
|
||||
const webampRef = useRef<import("webamp").default | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function loadWebamp() {
|
||||
// @ts-ignore
|
||||
const { default: Webamp } = await import("webamp");
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
const webamp = new Webamp({
|
||||
initialSkin: { url: skinUrl },
|
||||
initialTracks,
|
||||
enableHotkeys: true,
|
||||
zIndex: 1001,
|
||||
});
|
||||
|
||||
webampRef.current = webamp;
|
||||
cleanup = () => webamp.dispose();
|
||||
|
||||
webamp.onClose(closeModal);
|
||||
// ref.current!.style.opacity = "0";
|
||||
await webamp.renderInto(ref.current!);
|
||||
const { width } = outerRef.current!.getBoundingClientRect();
|
||||
const zoom = width / SCREENSHOT_WIDTH;
|
||||
document
|
||||
.getElementById("webamp")
|
||||
?.style.setProperty("zoom", String(zoom));
|
||||
ref.current!.style.opacity = "1";
|
||||
|
||||
if (!disposed) loaded();
|
||||
}
|
||||
|
||||
loadWebamp();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [skinUrl, closeModal, loaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
style={{
|
||||
top: 0,
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="webamp-container"
|
||||
style={{
|
||||
width: SCREENSHOT_WIDTH,
|
||||
height: SCREENSHOT_HEIGHT,
|
||||
position: "relative",
|
||||
opacity: 0,
|
||||
transition: "opacity 1s linear",
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const album = "netBloc Vol. 24: tiuqottigeloot";
|
||||
|
||||
const initialTracks = [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
url: "/llama.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
|
||||
duration: 322.612245,
|
||||
metaData: {
|
||||
title: "Heroines",
|
||||
artist: "Diablo Swing Orchestra",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
|
||||
duration: 190.093061,
|
||||
metaData: {
|
||||
title: "We Are Going To Eclecfunk Your Ass",
|
||||
artist: "Eclectek",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Auto-Pilot_-_03_-_Seventeen.mp3",
|
||||
duration: 214.622041,
|
||||
metaData: {
|
||||
title: "Seventeen",
|
||||
artist: "Auto-Pilot",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Muha_-_04_-_Microphone.mp3",
|
||||
duration: 181.838367,
|
||||
metaData: {
|
||||
title: "Microphone",
|
||||
artist: "Muha",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Just_Plain_Ant_-_05_-_Stumble.mp3",
|
||||
duration: 86.047347,
|
||||
metaData: {
|
||||
title: "Stumble",
|
||||
artist: "Just Plain Ant",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Sleaze_-_06_-_God_Damn.mp3",
|
||||
duration: 226.795102,
|
||||
metaData: {
|
||||
title: "God Damn",
|
||||
artist: "Sleaze",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Juanitos_-_07_-_Hola_Hola_Bossa_Nova.mp3",
|
||||
duration: 207.072653,
|
||||
metaData: {
|
||||
title: "Hola Hola Bossa Nova",
|
||||
artist: "Juanitos",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Entertainment_for_the_Braindead_-_08_-_Resolutions_Chris_Summer_Remix.mp3",
|
||||
duration: 314.331429,
|
||||
metaData: {
|
||||
title: "Resolutions (Chris Summer Remix)",
|
||||
artist: "Entertainment for the Braindead",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Nobara_Hayakawa_-_09_-_Trail.mp3",
|
||||
duration: 204.042449,
|
||||
metaData: {
|
||||
title: "Trail",
|
||||
artist: "Nobara Hayakawa",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Paper_Navy_-_10_-_Tongue_Tied.mp3",
|
||||
duration: 201.116735,
|
||||
metaData: {
|
||||
title: "Tongue Tied",
|
||||
artist: "Paper Navy",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/60_Tigres_-_11_-_Garage.mp3",
|
||||
duration: 245.394286,
|
||||
metaData: {
|
||||
title: "Garage",
|
||||
artist: "60 Tigres",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/CM_aka_Creative_-_12_-_The_Cycle_Featuring_Mista_Mista.mp3",
|
||||
duration: 221.44,
|
||||
metaData: {
|
||||
title: "The Cycle (Featuring Mista Mista)",
|
||||
artist: "CM aka Creative",
|
||||
album,
|
||||
},
|
||||
},
|
||||
];
|
||||
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal file
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import StaticPage, {
|
||||
Heading,
|
||||
Subheading,
|
||||
Link,
|
||||
Paragraph,
|
||||
} from "../StaticPage";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>About</Heading>
|
||||
<Paragraph>
|
||||
The Winamp Skin Museum is an attempt to build a <i>fast</i>,{" "}
|
||||
<i>searchable</i>, and <i>shareable</i>, interface for the collection of
|
||||
Winamp Skins amassed on the{" "}
|
||||
<Link
|
||||
href="https://archive.org/details/winampskins"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Internet Archive
|
||||
</Link>
|
||||
.
|
||||
</Paragraph>
|
||||
<Subheading>Features:</Subheading>
|
||||
<ul style={{ marginBottom: "1.5rem", paddingLeft: "1.5rem" }}>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Infinite scroll</strong> preview images
|
||||
</li>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Experience</strong> skins with integrated{" "}
|
||||
<Link
|
||||
href="https://webamp.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Webamp
|
||||
</Link>
|
||||
</li>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Fast search</strong> of indexed readme.txt texts
|
||||
</li>
|
||||
</ul>
|
||||
<Paragraph>
|
||||
Made by <Link href="https://jordaneldredge.com">Jordan Eldredge</Link>
|
||||
</Paragraph>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
margin: "2rem 0",
|
||||
}}
|
||||
/>
|
||||
<Paragraph>
|
||||
Want Winamp on your Windows PC, but with supported updates & new
|
||||
features?{" "}
|
||||
<Link
|
||||
href="https://getwacup.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Try WACUP
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { algoliasearch } from "algoliasearch";
|
||||
|
||||
// Using the legacy hardcoded credentials for client-side search
|
||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||
|
||||
export async function searchSkins(query: string) {
|
||||
const result = await client.searchSingleIndex({
|
||||
indexName: "Skins",
|
||||
searchParams: {
|
||||
query,
|
||||
attributesToRetrieve: ["objectID", "fileName", "nsfw"],
|
||||
attributesToHighlight: [],
|
||||
hitsPerPage: 1000,
|
||||
typoTolerance: "min",
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal file
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import StaticPage, {
|
||||
Heading,
|
||||
Paragraph,
|
||||
Label,
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
} from "../StaticPage";
|
||||
|
||||
async function sendFeedback(variables: {
|
||||
message: string;
|
||||
email: string;
|
||||
url: string;
|
||||
}) {
|
||||
const mutation = `
|
||||
mutation GiveFeedback($message: String!, $email: String, $url: String) {
|
||||
send_feedback(message: $message, email: $email, url: $url)
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch("/api/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send feedback");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const pathname = usePathname();
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (message.trim().length === 0) {
|
||||
alert("Please add a message before sending.");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
message,
|
||||
email,
|
||||
url: "https://skins.webamp.org" + pathname,
|
||||
};
|
||||
setSending(true);
|
||||
try {
|
||||
await sendFeedback(body);
|
||||
setSent(true);
|
||||
} catch (_) {
|
||||
alert("Failed to send feedback. Please try again.");
|
||||
setSending(false);
|
||||
}
|
||||
}, [message, email, pathname]);
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>Sent!</Heading>
|
||||
<Paragraph>
|
||||
Thanks for your feedback. I appreciate you taking the time to share
|
||||
your thoughts.
|
||||
</Paragraph>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>Feedback</Heading>
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
Let me know what you think about the Winamp Skin Museum. Bug reports,
|
||||
feature suggestions, personal anecdotes, or criticism are all welcome.
|
||||
</p>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<Label>Message</Label>
|
||||
<Textarea
|
||||
disabled={sending}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
style={{ minHeight: 150 }}
|
||||
placeholder="Your thoughts here..."
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<Label>Email (optional)</Label>
|
||||
<Input
|
||||
disabled={sending}
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button onClick={send} disabled={sending}>
|
||||
{sending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal file
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import UserContext from "../../../data/UserContext";
|
||||
import SessionModel from "../../../data/SessionModel";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import { getScrollPage } from "../../../data/skins";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
|
||||
"use server";
|
||||
const ctx = new UserContext();
|
||||
|
||||
const page = await getScrollPage(sessionId);
|
||||
|
||||
return await Promise.all(
|
||||
page.map(async (item) => {
|
||||
return getSkinForSession(ctx, sessionId, item.md5);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSkinForSession(
|
||||
ctx: UserContext,
|
||||
sessionId: string,
|
||||
md5: string
|
||||
): Promise<ClientSkin> {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, md5);
|
||||
const readmeText = await model.getReadme();
|
||||
const fileName = await model.getFileName();
|
||||
const tweet = await model.getTweet();
|
||||
const likeCount = tweet ? tweet.getLikes() : 0;
|
||||
|
||||
SessionModel.addSkin(sessionId, md5);
|
||||
|
||||
return {
|
||||
screenshotUrl: model.getScreenshotUrl(),
|
||||
skinUrl: model.getSkinUrl(),
|
||||
md5,
|
||||
// TODO: Normalize to .wsz
|
||||
fileName: fileName,
|
||||
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
|
||||
downloadUrl: model.getSkinUrl(),
|
||||
shareUrl: `https://skins.webamp.org/scroll/skin/${md5}`,
|
||||
nsfw: await model.getIsNsfw(),
|
||||
likeCount: likeCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use server";
|
||||
|
||||
import { getMuseumPage, getScreenshotUrl } from "../../../data/skins";
|
||||
|
||||
export type GridSkin = {
|
||||
md5: string;
|
||||
screenshotUrl: string;
|
||||
fileName: string;
|
||||
nsfw: boolean;
|
||||
};
|
||||
|
||||
export async function getMuseumPageSkins(
|
||||
offset: number,
|
||||
limit: number
|
||||
): Promise<GridSkin[]> {
|
||||
const page = await getMuseumPage({ offset, first: limit });
|
||||
|
||||
const skins = page.map((item) => ({
|
||||
md5: item.md5,
|
||||
screenshotUrl: getScreenshotUrl(item.md5),
|
||||
fileName: item.fileName,
|
||||
nsfw: item.nsfw,
|
||||
}));
|
||||
|
||||
return skins;
|
||||
}
|
||||
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
import { unstable_ViewTransition as ViewTransition, ReactNode } from "react";
|
||||
import BottomMenuBar from "./BottomMenuBar";
|
||||
import "./scroll.css";
|
||||
|
||||
type LayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ViewTransition>
|
||||
<BottomMenuBar />
|
||||
</ViewTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal file
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import Grid from "./Grid";
|
||||
import { getMuseumPageSkins } from "./getMuseumPageSkins";
|
||||
import * as Skins from "../../..//data/skins";
|
||||
|
||||
export default async function SkinTable() {
|
||||
const [initialSkins, skinCount] = await Promise.all([
|
||||
getMuseumPageSkins(0, 50),
|
||||
Skins.getClassicSkinCount(),
|
||||
]);
|
||||
return <Grid initialSkins={initialSkins} initialTotal={skinCount} />;
|
||||
}
|
||||
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal file
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
body {
|
||||
margin: 0; /* Remove default margin */
|
||||
height: 100vh; /* Set body height to viewport height */
|
||||
background-color: #1a1a1a; /* Dark charcoal instead of pure black */
|
||||
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar,
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
.scroller,
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Metadata } from "next";
|
||||
import SessionModel from "../../../../../data/SessionModel";
|
||||
import UserContext from "../../../../../data/UserContext";
|
||||
import { getClientSkins, getSkinForSession } from "../../getClientSkins";
|
||||
import SkinScroller from "../../SkinScroller";
|
||||
import { generateSkinPageMetadata } from "../../../../(legacy)/skin/[hash]/skinMetadata";
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { md5 } = await params;
|
||||
return generateSkinPageMetadata(md5);
|
||||
}
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Skin({ params }) {
|
||||
const { md5 } = await params;
|
||||
|
||||
// Create the session in the database
|
||||
const sessionId = await SessionModel.create();
|
||||
|
||||
const ctx = new UserContext();
|
||||
const linked = await getSkinForSession(ctx, sessionId, md5);
|
||||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={[linked, ...initialSkins]}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal file
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import SessionModel from "../../../../data/SessionModel";
|
||||
import { getClientSkins } from "../getClientSkins";
|
||||
import SkinScroller from "../SkinScroller";
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A tik-tok style scroll page where we display one skin at a time in full screen
|
||||
*/
|
||||
export default async function ScrollPage() {
|
||||
// Create the session in the database
|
||||
const sessionId = await SessionModel.create();
|
||||
|
||||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={initialSkins}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal file
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
type UseScrollHintOptions = {
|
||||
containerRef: HTMLDivElement | null;
|
||||
enabled: boolean;
|
||||
delayMs?: number;
|
||||
scrollAmount?: number;
|
||||
animationDuration?: number;
|
||||
onHintShown?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that provides a gentle scroll hint animation to encourage user interaction.
|
||||
* After a delay, if the user hasn't scrolled, it will scroll down slightly and bounce back.
|
||||
*/
|
||||
export function useScrollHint({
|
||||
containerRef,
|
||||
enabled,
|
||||
delayMs = 2000,
|
||||
scrollAmount = 80,
|
||||
animationDuration = 1000,
|
||||
onHintShown,
|
||||
}: UseScrollHintOptions) {
|
||||
useEffect(() => {
|
||||
if (containerRef == null || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hintTimer = setTimeout(() => {
|
||||
if (!enabled || containerRef.scrollTop !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startScrollTop = containerRef.scrollTop;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Temporarily disable scroll snap for smooth animation
|
||||
const originalScrollSnapType = containerRef.style.scrollSnapType;
|
||||
containerRef.style.scrollSnapType = "none";
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
// Bouncy easing function - overshoots and bounces back
|
||||
const easeOutBounce = (t: number) => {
|
||||
const n1 = 7.5625;
|
||||
const d1 = 2.75;
|
||||
if (t < 1 / d1) {
|
||||
return n1 * t * t;
|
||||
} else if (t < 2 / d1) {
|
||||
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
||||
} else if (t < 2.5 / d1) {
|
||||
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
||||
} else {
|
||||
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a bounce effect: scroll down quickly, then bounce back
|
||||
let offset;
|
||||
if (progress < 0.4) {
|
||||
// First 40%: scroll down quickly
|
||||
const t = progress / 0.4;
|
||||
offset = scrollAmount * t * t; // Quadratic ease-in
|
||||
} else {
|
||||
// Last 60%: bounce back with overshoot
|
||||
const t = (progress - 0.4) / 0.6;
|
||||
offset = scrollAmount * (1 - easeOutBounce(t));
|
||||
}
|
||||
|
||||
containerRef.scrollTop = startScrollTop + offset;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Ensure we end exactly where we started
|
||||
containerRef.scrollTop = startScrollTop;
|
||||
// Re-enable scroll snap
|
||||
containerRef.style.scrollSnapType = originalScrollSnapType;
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
onHintShown?.();
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(hintTimer);
|
||||
};
|
||||
}, [
|
||||
containerRef,
|
||||
enabled,
|
||||
delayMs,
|
||||
scrollAmount,
|
||||
animationDuration,
|
||||
onHintShown,
|
||||
]);
|
||||
}
|
||||
161
packages/skin-database/app/(modern)/table/Table.tsx
Normal file
161
packages/skin-database/app/(modern)/table/Table.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
import {
|
||||
HEADING_HEIGHT,
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
} from "../../../legacy-client/src/constants.js";
|
||||
import {
|
||||
useScrollbarWidth,
|
||||
useWindowSize,
|
||||
} from "../../../legacy-client/src/hooks.js";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
|
||||
function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
return mounted ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
export default function WrappedTable({ initialSkins, skinCount }) {
|
||||
return (
|
||||
<ClientOnly>
|
||||
<Table initialSkins={initialSkins} skinCount={skinCount} />
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ initialSkins, skinCount }) {
|
||||
const skins = initialSkins;
|
||||
|
||||
const scale = 0.5; // This can be adjusted based on your needs
|
||||
function getSkinData(data: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
columnCount: number;
|
||||
}) {
|
||||
const index = data.rowIndex * columnCount + data.columnIndex;
|
||||
const skin = skins[index];
|
||||
return { requestToken: skin?.md5, skin };
|
||||
}
|
||||
const scrollbarWidth = useScrollbarWidth();
|
||||
const { windowWidth: windowWidthWithScrollabar, windowHeight } =
|
||||
useWindowSize();
|
||||
|
||||
const { columnWidth, rowHeight, columnCount } = getTableDimensions(
|
||||
windowWidthWithScrollabar - scrollbarWidth,
|
||||
scale
|
||||
);
|
||||
function Cell(props) {
|
||||
const index = props.rowIndex * columnCount + props.columnIndex;
|
||||
const skin = skins[index];
|
||||
if (skin == null) {
|
||||
if (index < skinCount) {
|
||||
// Fetch more skins!
|
||||
}
|
||||
return <div style={props.style}></div>;
|
||||
}
|
||||
if (skin == null) {
|
||||
return <div style={props.style}>Loading...</div>;
|
||||
}
|
||||
const imageUrl = `https://r2.webampskins.org/screenshots/${skin.md5}.png`;
|
||||
return (
|
||||
<div style={props.style}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
style={{ width: props.data.width, height: props.data.height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SkinTableUnbound
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
rowHeight={rowHeight}
|
||||
windowHeight={windowHeight}
|
||||
windowWidth={windowWidthWithScrollabar}
|
||||
skinCount={skinCount}
|
||||
getSkinData={getSkinData}
|
||||
Cell={Cell}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const getTableDimensions = (windowWidth: number, scale: number) => {
|
||||
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * scale));
|
||||
const columnWidth = windowWidth / columnCount; // TODO: Consider flooring this to get things aligned to the pixel
|
||||
const rowHeight = columnWidth * SKIN_RATIO;
|
||||
return { columnWidth, rowHeight, columnCount };
|
||||
};
|
||||
|
||||
function SkinTableUnbound({
|
||||
columnCount,
|
||||
columnWidth,
|
||||
rowHeight,
|
||||
windowHeight,
|
||||
skinCount,
|
||||
windowWidth,
|
||||
getSkinData,
|
||||
Cell,
|
||||
}) {
|
||||
function itemKey({ columnIndex, rowIndex }) {
|
||||
const { requestToken, data: skin } = getSkinData({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
columnCount,
|
||||
});
|
||||
if (skin == null && requestToken == null) {
|
||||
return `empty-cell-${columnIndex}-${rowIndex}`;
|
||||
}
|
||||
return skin ? skin.hash : `unfectched-index-${requestToken}`;
|
||||
}
|
||||
const gridRef = React.useRef<any>(null);
|
||||
const itemRef = React.useRef<number>(0);
|
||||
React.useLayoutEffect(() => {
|
||||
if (gridRef.current == null) {
|
||||
return;
|
||||
}
|
||||
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
|
||||
}, [skinCount]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (gridRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemRow = Math.floor(itemRef.current / columnCount);
|
||||
|
||||
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: rowHeight * itemRow });
|
||||
}, [rowHeight, columnCount]);
|
||||
|
||||
const onScroll = useMemo(() => {
|
||||
const half = Math.round(columnCount / 2);
|
||||
return (scrollData) => {
|
||||
itemRef.current =
|
||||
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
|
||||
};
|
||||
}, [columnCount, rowHeight]);
|
||||
|
||||
return (
|
||||
<div id="infinite-skins" style={{ marginTop: HEADING_HEIGHT }}>
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
itemKey={itemKey}
|
||||
itemData={{ columnCount, width: columnWidth, height: rowHeight }}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={windowHeight - HEADING_HEIGHT}
|
||||
rowCount={Math.ceil(skinCount / columnCount)}
|
||||
rowHeight={rowHeight}
|
||||
width={windowWidth}
|
||||
overscanRowCount={5}
|
||||
onScroll={onScroll}
|
||||
style={{ overflowY: "scroll" }}
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
packages/skin-database/app/(modern)/table/page.tsx
Normal file
17
packages/skin-database/app/(modern)/table/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as Skins from "../../../data/skins";
|
||||
import Table from "./Table";
|
||||
|
||||
export default async function TablePage() {
|
||||
const skins = await Skins.getMuseumPage({
|
||||
offset: 0,
|
||||
first: 100,
|
||||
});
|
||||
|
||||
const skinCount = await Skins.getClassicSkinCount();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Table initialSkins={skins} skinCount={skinCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { fetchGraphql, gql } from "../../legacy-client/src/utils";
|
||||
|
||||
interface BulkDownloadSkin {
|
||||
md5: string;
|
||||
filename: string;
|
||||
download_url: string;
|
||||
__typename: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
totalSkins: number;
|
||||
completedSkins: number;
|
||||
failedSkins: number;
|
||||
estimatedSizeBytes: string;
|
||||
activeDownloads: Array<{
|
||||
filename: string;
|
||||
md5: string;
|
||||
status: "downloading" | "failed";
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DirectoryHandle {
|
||||
name: string;
|
||||
getDirectoryHandle: (
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
) => Promise<DirectoryHandle>;
|
||||
getFileHandle: (
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
) => Promise<FileSystemFileHandle>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
showDirectoryPicker?: () => Promise<DirectoryHandle>;
|
||||
}
|
||||
}
|
||||
|
||||
const BULK_DOWNLOAD_QUERY = gql`
|
||||
query BulkDownload($offset: Int!, $first: Int!) {
|
||||
bulkDownload(offset: $offset, first: $first) {
|
||||
totalCount
|
||||
estimatedSizeBytes
|
||||
nodes {
|
||||
__typename
|
||||
md5
|
||||
filename(normalize_extension: true, include_museum_id: true)
|
||||
download_url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 6;
|
||||
const CHUNK_SIZE = 1000;
|
||||
|
||||
export default function BulkDownloadPage() {
|
||||
const [directoryHandle, setDirectoryHandle] =
|
||||
useState<DirectoryHandle | null>(null);
|
||||
const [progress, setProgress] = useState<DownloadProgress>({
|
||||
totalSkins: 0,
|
||||
completedSkins: 0,
|
||||
failedSkins: 0,
|
||||
estimatedSizeBytes: "0",
|
||||
activeDownloads: [],
|
||||
});
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSupported] = useState(
|
||||
typeof window !== "undefined" && "showDirectoryPicker" in window
|
||||
);
|
||||
const abortController = useRef<AbortController | null>(null);
|
||||
|
||||
const downloadSkin = useCallback(
|
||||
async (
|
||||
skin: BulkDownloadSkin,
|
||||
directoryHandle: DirectoryHandle,
|
||||
signal: AbortSignal
|
||||
): Promise<void> => {
|
||||
const { filename, download_url, md5 } = skin;
|
||||
|
||||
// Get the target directory and file path
|
||||
const targetDirectory = await getDirectoryForSkin(
|
||||
filename,
|
||||
directoryHandle
|
||||
);
|
||||
// Check if file already exists
|
||||
try {
|
||||
await targetDirectory.getFileHandle(filename);
|
||||
// File exists, skip download
|
||||
console.log(`Skipping ${filename} - already exists`);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedSkins: prev.completedSkins + 1,
|
||||
}));
|
||||
return;
|
||||
} catch (_) {
|
||||
// File doesn't exist, continue with download
|
||||
}
|
||||
|
||||
// Add to active downloads
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
activeDownloads: [
|
||||
...prev.activeDownloads,
|
||||
{
|
||||
filename,
|
||||
md5,
|
||||
status: "downloading" as const,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch(download_url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// We don't need individual progress tracking anymore
|
||||
// const contentLength = parseInt(response.headers.get("content-length") || "0", 10);
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
// Use the targetDirectory and finalFilename we calculated earlier
|
||||
const fileHandle = await targetDirectory.getFileHandle(filename, {
|
||||
create: true,
|
||||
});
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
// Track total bytes for this file (not needed for individual progress)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
await writable.write(value);
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
|
||||
// Mark as completed and immediately remove from active downloads
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedSkins: prev.completedSkins + 1,
|
||||
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
|
||||
}));
|
||||
} catch (writeError) {
|
||||
await writable.abort("Failed to write file");
|
||||
throw writeError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
console.log(`Download aborted: ${filename}`);
|
||||
throw error; // Re-throw abort errors
|
||||
}
|
||||
|
||||
// Mark as failed and schedule removal
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
failedSkins: prev.failedSkins + 1,
|
||||
activeDownloads: prev.activeDownloads.map((d) =>
|
||||
d.md5 === md5
|
||||
? {
|
||||
...d,
|
||||
status: "failed" as const,
|
||||
error: error.message,
|
||||
}
|
||||
: d
|
||||
),
|
||||
}));
|
||||
|
||||
// Remove failed download after 3 seconds
|
||||
setTimeout(() => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
|
||||
}));
|
||||
}, 3000);
|
||||
|
||||
console.error(`Failed to download ${filename}:`, error);
|
||||
}
|
||||
},
|
||||
[getDirectoryForSkin]
|
||||
);
|
||||
|
||||
// Load initial metadata when component mounts
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
totalSkins: totalCount,
|
||||
estimatedSizeBytes,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load initial data:", error);
|
||||
setError("Failed to load skin count information");
|
||||
}
|
||||
}
|
||||
|
||||
loadInitialData();
|
||||
}, [fetchSkins]);
|
||||
|
||||
const selectDirectoryAndStart = useCallback(async () => {
|
||||
// First, select directory if not already selected
|
||||
if (!directoryHandle) {
|
||||
if (!window.showDirectoryPicker) {
|
||||
setError(
|
||||
"File System Access API is not supported in this browser. Please use Chrome or Edge."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
setDirectoryHandle(handle);
|
||||
setError(null);
|
||||
|
||||
// Now start the download with the new directory
|
||||
await startDownloadWithDirectory(handle as FileSystemDirectoryHandle);
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setError(`Failed to select directory: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Directory already selected, just start download
|
||||
await startDownloadWithDirectory(
|
||||
directoryHandle as FileSystemDirectoryHandle
|
||||
);
|
||||
}
|
||||
}, [directoryHandle]);
|
||||
|
||||
const startDownloadWithDirectory = useCallback(
|
||||
async (handle: FileSystemDirectoryHandle) => {
|
||||
setIsDownloading(true);
|
||||
setError(null);
|
||||
// setStartTime(Date.now());
|
||||
abortController.current = new AbortController();
|
||||
|
||||
try {
|
||||
// Get initial metadata
|
||||
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
|
||||
|
||||
setProgress({
|
||||
totalSkins: totalCount,
|
||||
completedSkins: 0,
|
||||
failedSkins: 0,
|
||||
estimatedSizeBytes,
|
||||
activeDownloads: [],
|
||||
});
|
||||
|
||||
let offset = 0;
|
||||
const activePromises = new Set<Promise<void>>();
|
||||
|
||||
while (offset < totalCount && !abortController.current.signal.aborted) {
|
||||
console.log(`Fetching batch: offset=${offset}, chunk=${CHUNK_SIZE}`);
|
||||
|
||||
try {
|
||||
const { skins } = await fetchSkins(offset, CHUNK_SIZE);
|
||||
console.log(`Retrieved ${skins.length} skins in this batch`);
|
||||
|
||||
if (skins.length === 0) {
|
||||
console.log("No more skins to fetch, breaking");
|
||||
break;
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
// eslint-disable-next-line max-depth
|
||||
if (abortController.current.signal.aborted) break;
|
||||
|
||||
await waitForAvailableSlot(
|
||||
activePromises,
|
||||
abortController.current.signal
|
||||
);
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (abortController.current.signal.aborted) break;
|
||||
|
||||
const downloadPromise = downloadSkin(
|
||||
skin,
|
||||
handle,
|
||||
abortController.current.signal
|
||||
).finally(() => {
|
||||
activePromises.delete(downloadPromise);
|
||||
});
|
||||
|
||||
activePromises.add(downloadPromise);
|
||||
}
|
||||
|
||||
offset += skins.length;
|
||||
console.log(`Completed batch, new offset: ${offset}/${totalCount}`);
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch batch at offset ${offset}:`, error);
|
||||
setError(`Failed to fetch skins: ${error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all remaining downloads to complete
|
||||
await Promise.allSettled(activePromises);
|
||||
console.log("All downloads completed!");
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
setError(`Download failed: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[fetchSkins, downloadSkin]
|
||||
);
|
||||
|
||||
const stopDownload = useCallback(() => {
|
||||
if (abortController.current) {
|
||||
abortController.current.abort("User Canceled");
|
||||
}
|
||||
setIsDownloading(false);
|
||||
// setStartTime(null);
|
||||
}, []);
|
||||
|
||||
const progressPercent =
|
||||
progress.totalSkins > 0
|
||||
? ((progress.completedSkins + progress.failedSkins) /
|
||||
progress.totalSkins) *
|
||||
100
|
||||
: 0;
|
||||
|
||||
if (!isSupported) {
|
||||
return <h1>Your browser does not support filesystem access.</h1>;
|
||||
}
|
||||
|
||||
const gb = Math.round(
|
||||
parseInt(progress.estimatedSizeBytes || "0", 10) / (1024 * 1024 * 1024)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<h1>Bulk Download All Skins</h1>
|
||||
<p>Download the entire Winamp Skin Museum collection.</p>
|
||||
<ul>
|
||||
<li>
|
||||
Will download {progress.totalSkins.toLocaleString()} files (~
|
||||
{gb}
|
||||
GB) into the selected directory
|
||||
</li>
|
||||
<li>
|
||||
Files will be organized into directories (aa-zz, 0-9) based on
|
||||
filename prefix
|
||||
</li>
|
||||
<li>
|
||||
Supports resuming from previously interrupted bulk download
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Controls */}
|
||||
<div>
|
||||
{isDownloading ? (
|
||||
<button onClick={stopDownload}>Stop Download</button>
|
||||
) : (
|
||||
<button onClick={selectDirectoryAndStart}>
|
||||
{directoryHandle
|
||||
? "Start Download"
|
||||
: "Select Directory & Start Download"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(isDownloading || progress.completedSkins > 0) && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span>
|
||||
Downloaded{" "}
|
||||
{(
|
||||
progress.completedSkins + progress.failedSkins
|
||||
).toLocaleString()}{" "}
|
||||
of {progress.totalSkins.toLocaleString()} skins
|
||||
</span>
|
||||
<span>{Math.round(progressPercent)}% complete</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "black",
|
||||
transition: "all 300ms",
|
||||
height: "18px",
|
||||
width: `${progressPercent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getDirectoryForSkin(
|
||||
filename: string,
|
||||
rootHandle: DirectoryHandle
|
||||
) {
|
||||
// Create directory based on first two characters of filename (case insensitive)
|
||||
const firstChar = filename.charAt(0).toLowerCase();
|
||||
const secondChar =
|
||||
filename.length > 1 ? filename.charAt(1).toLowerCase() : "";
|
||||
|
||||
let dirName: string;
|
||||
if (/[a-z]/.test(firstChar)) {
|
||||
// For letters, use two-character prefix if second char is alphanumeric
|
||||
if (/[a-z0-9]/.test(secondChar)) {
|
||||
dirName = firstChar + secondChar;
|
||||
} else {
|
||||
// Fallback to single letter + 'x' for special characters
|
||||
dirName = firstChar + "x";
|
||||
}
|
||||
} else {
|
||||
// For numbers/symbols, use "0-9"
|
||||
dirName = "0-9";
|
||||
}
|
||||
|
||||
try {
|
||||
return await rootHandle.getDirectoryHandle(dirName, { create: true });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to create directory ${dirName}, using root:`, err);
|
||||
return rootHandle;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSkins(
|
||||
offset: number,
|
||||
first: number
|
||||
): Promise<{
|
||||
skins: BulkDownloadSkin[];
|
||||
totalCount: number;
|
||||
estimatedSizeBytes: string;
|
||||
}> {
|
||||
const { bulkDownload } = await fetchGraphql(BULK_DOWNLOAD_QUERY, {
|
||||
offset,
|
||||
first,
|
||||
});
|
||||
return {
|
||||
skins: bulkDownload.nodes,
|
||||
totalCount: bulkDownload.totalCount,
|
||||
estimatedSizeBytes: bulkDownload.estimatedSizeBytes,
|
||||
};
|
||||
}
|
||||
// Helper function to wait for an available download slot
|
||||
async function waitForAvailableSlot(
|
||||
activePromises: Set<Promise<void>>,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
while (activePromises.size >= MAX_CONCURRENT_DOWNLOADS && !signal.aborted) {
|
||||
await Promise.race(activePromises);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ const { handleRequest } = createYogaInstance({
|
|||
return new UserContext();
|
||||
},
|
||||
logger: {
|
||||
log: (message: string, context: Record<string, any>) => {
|
||||
console.log(message, context);
|
||||
log: (_message: string, _context: Record<string, any>) => {
|
||||
// console.log(message, context);
|
||||
},
|
||||
logError: (message: string, context: Record<string, any>) => {
|
||||
console.error(message, context);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import App from "../App";
|
||||
import App from "./App";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
const DESCRIPTION =
|
||||
|
|
@ -35,11 +35,14 @@ import { setHashesForSkin } from "./skinHash";
|
|||
import * as S3 from "./s3";
|
||||
import { generateDescription } from "./services/openAi";
|
||||
import KeyValue from "./data/KeyValue";
|
||||
import { postToBluesky } from "./tasks/bluesky";
|
||||
import { computeSkinRankings } from "./tasks/computeScrollRanking";
|
||||
|
||||
async function withHandler(
|
||||
cb: (handler: DiscordEventHandler) => Promise<void>
|
||||
) {
|
||||
const handler = new DiscordEventHandler();
|
||||
await handler._clientPromise; // Ensure client is initialized
|
||||
try {
|
||||
await cb(handler);
|
||||
} finally {
|
||||
|
|
@ -81,21 +84,30 @@ program
|
|||
.argument("[md5]", "md5 of the skin to share")
|
||||
.option("-t, --twitter", "Share on Twitter")
|
||||
.option("-i, --instagram", "Share on Instagram")
|
||||
.option("-b, --bluesky", "Share on Bluesky")
|
||||
.option("-m, --mastodon", "Share on Mastodon")
|
||||
.action(async (md5, { twitter, instagram, mastodon }) => {
|
||||
if (!twitter && !instagram && !mastodon) {
|
||||
throw new Error("Expected at least one of --twitter or --instagram");
|
||||
}
|
||||
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
|
||||
await withDiscordClient(async (client) => {
|
||||
if (twitter) {
|
||||
await tweet(client, md5);
|
||||
return;
|
||||
}
|
||||
if (instagram) {
|
||||
await insta(client, md5);
|
||||
return;
|
||||
}
|
||||
if (mastodon) {
|
||||
await postToMastodon(client, md5);
|
||||
return;
|
||||
}
|
||||
if (bluesky) {
|
||||
await postToBluesky(client, md5);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -161,7 +173,7 @@ program
|
|||
console.log("====================================");
|
||||
}
|
||||
if (purge) {
|
||||
// cat purge | xargs -I {} yarn cli skin --purge {}
|
||||
// cat purge | xargs -I {} pnpm cli skin --purge {}
|
||||
await Skins.deleteSkin(md5);
|
||||
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
|
||||
const purged = new Set(purgedArr);
|
||||
|
|
@ -220,14 +232,21 @@ program
|
|||
if (screenshot) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const md5 = md5Buffer(buffer);
|
||||
const tempPath = temp.path({ suffix: ".png" });
|
||||
const tempSkinFile = temp.path({ suffix: ".wsz" });
|
||||
const tempScreenshotPath = temp.path({ suffix: ".png" });
|
||||
|
||||
// Write buffer to temporary file as Puppeteer's uploadFile expects a file path
|
||||
fs.writeFileSync(tempSkinFile, new Uint8Array(buffer));
|
||||
|
||||
await Shooter.withShooter(
|
||||
async (shooter: Shooter) => {
|
||||
await shooter.takeScreenshot(buffer, tempPath, { md5 });
|
||||
await shooter.takeScreenshot(tempSkinFile, tempScreenshotPath, {
|
||||
md5,
|
||||
});
|
||||
},
|
||||
(message: string) => console.log(message)
|
||||
);
|
||||
console.log("Screenshot complete", tempPath);
|
||||
console.log("Screenshot complete", tempScreenshotPath);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -291,6 +310,14 @@ program
|
|||
console.table([await Skins.getStats()]);
|
||||
});
|
||||
|
||||
program
|
||||
.command("compute-scroll-ranking")
|
||||
.description("Analyze user event data and compute skin ranking scores.")
|
||||
.action(async () => {
|
||||
const rankings = await computeSkinRankings();
|
||||
console.log(JSON.stringify(rankings, null, 2));
|
||||
});
|
||||
|
||||
program
|
||||
.command("process-uploads")
|
||||
.description("Process any unprocessed user uploads.")
|
||||
|
|
@ -431,7 +458,7 @@ program
|
|||
);
|
||||
const md5s = rows.map((row) => row.md5);
|
||||
console.log(md5s.length);
|
||||
console.log(await Skins.updateSearchIndexs(ctx, md5s));
|
||||
console.log(await Skins.updateSearchIndexes(ctx, md5s));
|
||||
}
|
||||
if (refreshContentHash) {
|
||||
const ctx = new UserContext();
|
||||
|
|
@ -455,7 +482,6 @@ program
|
|||
"missingModernSkins"
|
||||
);
|
||||
const missingModernSkinsSet = new Set(missingModernSkins);
|
||||
const skins = {};
|
||||
for (const md5 of missingModernSkins!) {
|
||||
const skin = await SkinModel.fromMd5(ctx, md5);
|
||||
if (skin == null) {
|
||||
|
|
|
|||
|
|
@ -29,10 +29,12 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
|
|||
// Used for session encryption
|
||||
export const SECRET = env("SECRET");
|
||||
export const NODE_ENV = env("NODE_ENV") || "production";
|
||||
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
|
||||
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
|
||||
|
||||
function env(key: string): string {
|
||||
const value = process.env[key];
|
||||
if (value == null) {
|
||||
if (!value) {
|
||||
throw new Error(`Expected an environment variable "${key}"`);
|
||||
}
|
||||
return value;
|
||||
|
|
|
|||
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