mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 10:15:31 +00:00
Compare commits
78 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 |
148 changed files with 8200 additions and 796 deletions
100
.eslintrc
100
.eslintrc
|
|
@ -4,15 +4,11 @@
|
||||||
"jsx": true,
|
"jsx": true,
|
||||||
"sourceType": "module",
|
"sourceType": "module",
|
||||||
"ecmaFeatures": {
|
"ecmaFeatures": {
|
||||||
"jsx": true,
|
"jsx": true
|
||||||
"experimentalObjectRestSpread": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": ["prettier"],
|
"plugins": ["prettier", "@typescript-eslint"],
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
|
||||||
"version": "15.2"
|
|
||||||
},
|
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
"node": {
|
"node": {
|
||||||
"extensions": [".js", ".ts", ".tsx"]
|
"extensions": [".js", ".ts", ".tsx"]
|
||||||
|
|
@ -21,27 +17,85 @@
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"node": true,
|
"node": true,
|
||||||
"amd": true,
|
|
||||||
"es6": true,
|
"es6": true,
|
||||||
"jest": 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": {
|
"rules": {
|
||||||
"prettier/prettier": "error",
|
"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": "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
|
|
@ -50,6 +50,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
|
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
|
||||||
needs: [ci]
|
needs: [ci]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
|
|
@ -61,6 +64,8 @@ jobs:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
- name: Update npm to latest version
|
||||||
|
run: npm install -g npm@latest
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Restore build artifacts
|
- name: Restore build artifacts
|
||||||
|
|
@ -81,31 +86,40 @@ jobs:
|
||||||
cd ../winamp-eqf && 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:
|
env:
|
||||||
RELEASE_COMMIT_SHA: ${{ github.sha }}
|
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')
|
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||||
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Setting version to $VERSION for tagged release"
|
||||||
|
cd packages/webamp && npm version $VERSION --no-git-tag-version
|
||||||
|
cd ../ani-cursor && npm version $VERSION --no-git-tag-version
|
||||||
|
cd ../winamp-eqf && npm version $VERSION --no-git-tag-version
|
||||||
|
# TODO: Update version number in webampLazy.tsx if needed
|
||||||
- name: Publish ani-cursor to npm
|
- name: Publish ani-cursor to npm
|
||||||
working-directory: ./packages/ani-cursor
|
working-directory: ./packages/ani-cursor
|
||||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||||
run: |
|
run: |
|
||||||
npm publish ${TAG} --ignore-scripts
|
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||||
env:
|
npm publish --tag=next --ignore-scripts --provenance
|
||||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
else
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
npm publish --ignore-scripts --provenance
|
||||||
|
fi
|
||||||
- name: Publish winamp-eqf to npm
|
- name: Publish winamp-eqf to npm
|
||||||
working-directory: ./packages/winamp-eqf
|
working-directory: ./packages/winamp-eqf
|
||||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||||
run: |
|
run: |
|
||||||
npm publish ${TAG} --ignore-scripts
|
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||||
env:
|
npm publish --tag=next --ignore-scripts --provenance
|
||||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
else
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
npm publish --ignore-scripts --provenance
|
||||||
|
fi
|
||||||
- name: Publish webamp to npm
|
- name: Publish webamp to npm
|
||||||
working-directory: ./packages/webamp
|
working-directory: ./packages/webamp
|
||||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||||
# Use pre-built artifacts instead of rebuilding
|
# Use pre-built artifacts instead of rebuilding
|
||||||
run: |
|
run: |
|
||||||
npm publish ${TAG} --ignore-scripts
|
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||||
env:
|
npm publish --tag=next --ignore-scripts --provenance
|
||||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
else
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
npm publish --ignore-scripts --provenance
|
||||||
|
fi
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
node_modules
|
node_modules
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# Turborepo cache
|
# Turborepo cache
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ 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`](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/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/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-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/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
|
||||||
- [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
|
- [`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
|
- [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
pnpm --filter ani-cursor build
|
|
||||||
pnpm --filter webamp build
|
|
||||||
pnpm --filter webamp build-library
|
|
||||||
pnpm --filter webamp-modern build
|
|
||||||
mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern
|
|
||||||
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"]
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<!-- Webamp will attempt to center itself within this div -->
|
<!-- Webamp will attempt to center itself within this div -->
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import Webamp from "https://unpkg.com/webamp@^2";
|
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||||
const webamp = new Webamp({
|
const webamp = new Webamp({
|
||||||
initialTracks: [
|
initialTracks: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,11 @@
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
/**
|
/**
|
||||||
* Webamp includes a `webamp/butterchurn` entrypoint which includes the Butterchurn
|
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
|
||||||
* library to enable the Milkdrop visualizer. It hasn't been included in a
|
* entrypoint which includes the Butterchurn library to enable the
|
||||||
* stable version yet, until it is, you can use by installing
|
* Milkdrop visualizer.
|
||||||
* `webamp@next`.
|
|
||||||
*/
|
*/
|
||||||
import Webamp from "https://unpkg.com/webamp@0.0.0-next-7cdc35b/butterchurn";
|
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||||
const webamp = new Webamp({
|
const webamp = new Webamp({
|
||||||
initialTracks: [
|
initialTracks: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[build]
|
[build]
|
||||||
command = "pnpm deploy"
|
command = "pnpm run deploy"
|
||||||
publish = "packages/webamp/dist/demo-site/"
|
publish = "packages/webamp/dist/demo-site/"
|
||||||
|
|
||||||
# A short URL for listeners of https://changelog.com/podcast/291
|
# A short URL for listeners of https://changelog.com/podcast/291
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -13,9 +13,9 @@
|
||||||
"test:integration": "npx turbo run integration-tests",
|
"test:integration": "npx turbo run integration-tests",
|
||||||
"test:all": "npx turbo run test integration-tests",
|
"test:all": "npx turbo run test integration-tests",
|
||||||
"test:unit": "jest",
|
"test:unit": "jest",
|
||||||
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
|
"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",
|
"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": "sh deploy.sh",
|
"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}'"
|
"format": "prettier --write '**/*.{js,ts,tsx}'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -43,5 +43,10 @@
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "es5"
|
"trailingComma": "es5"
|
||||||
},
|
},
|
||||||
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"lint": "eslint src --ext ts,js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepublish": "tsc"
|
"prepublish": "tsc"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,28 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
extends: ["plugin:@typescript-eslint/recommended"],
|
||||||
node: true,
|
|
||||||
es2021: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 12,
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
rules: {
|
rules: {
|
||||||
// "no-console": "warn",
|
// Disable rules that conflict with the project's style
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
"@typescript-eslint/no-require-imports": "off", // Allow require() in JS files
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
"@typescript-eslint/ban-ts-comment": "off",
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-namespace": "off",
|
"@typescript-eslint/no-namespace": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
// Override the base no-shadow rule since it conflicts with TypeScript
|
||||||
"warn",
|
"no-shadow": "off",
|
||||||
{
|
// Relax rules for this project's existing style
|
||||||
argsIgnorePattern: "^_",
|
camelcase: "off",
|
||||||
varsIgnorePattern: "^_",
|
"dot-notation": "off",
|
||||||
caughtErrorsIgnorePattern: "^_",
|
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/**"],
|
ignorePatterns: ["dist/**"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
|
||||||
await setHashesForSkin(skin);
|
await setHashesForSkin(skin);
|
||||||
|
|
||||||
// Disable while we figure out our quota
|
// Disable while we figure out our quota
|
||||||
// await Skins.updateSearchIndex(ctx, md5);
|
await Skins.updateSearchIndex(ctx, md5);
|
||||||
|
|
||||||
return { md5, status: "ADDED", skinType: "CLASSIC" };
|
return { md5, status: "ADDED", skinType: "CLASSIC" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import UserContext from "../../data/UserContext.js";
|
||||||
|
|
||||||
/** @gqlContext */
|
/** @gqlContext */
|
||||||
|
|
@ -12,18 +7,3 @@ export type Ctx = Express.Request;
|
||||||
export function getUserContext(ctx: Ctx): UserContext {
|
export function getUserContext(ctx: Ctx): UserContext {
|
||||||
return ctx.ctx;
|
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 { ISkin } from "./CommonSkinResolver";
|
||||||
import { NodeResolver, toId } from "./NodeResolver";
|
import { NodeResolver, toId } from "./NodeResolver";
|
||||||
import ReviewResolver from "./ReviewResolver";
|
import ReviewResolver from "./ReviewResolver";
|
||||||
import path from "path";
|
|
||||||
import { ID, Int } from "grats";
|
import { ID, Int } from "grats";
|
||||||
import SkinModel from "../../../data/SkinModel";
|
import SkinModel from "../../../data/SkinModel";
|
||||||
|
|
||||||
|
|
@ -24,11 +23,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
||||||
return toId(this.__typename, this.md5());
|
return toId(this.__typename, this.md5());
|
||||||
}
|
}
|
||||||
async filename(normalize_extension?: boolean): Promise<string> {
|
async filename(normalize_extension?: boolean): Promise<string> {
|
||||||
const filename = await this._model.getFileName();
|
return await this._model.getFileName(normalize_extension);
|
||||||
if (normalize_extension) {
|
|
||||||
return path.parse(filename).name + ".wsz";
|
|
||||||
}
|
|
||||||
return filename;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
museum_url(): string {
|
museum_url(): string {
|
||||||
|
|
@ -46,7 +41,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
||||||
nsfw(): Promise<boolean> {
|
nsfw(): Promise<boolean> {
|
||||||
return this._model.getIsNsfw();
|
return this._model.getIsNsfw();
|
||||||
}
|
}
|
||||||
average_color(): string {
|
average_color(): string | null {
|
||||||
return this._model.getAverageColor();
|
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.
|
* has been uploaded under multiple names. Here we just pick one.
|
||||||
* @gqlField
|
* @gqlField
|
||||||
*/
|
*/
|
||||||
export function filename(
|
export async function filename(
|
||||||
skin: ISkin,
|
skin: ISkin,
|
||||||
{
|
{
|
||||||
normalize_extension = false,
|
normalize_extension = false,
|
||||||
|
include_museum_id = false,
|
||||||
}: {
|
}: {
|
||||||
/**
|
/**
|
||||||
* If true, the the correct file extension (.wsz or .wal) will be .
|
* If true, the the correct file extension (.wsz or .wal) will be .
|
||||||
* Otherwise, the original user-uploaded file extension will be used.
|
* Otherwise, the original user-uploaded file extension will be used.
|
||||||
*/
|
*/
|
||||||
normalize_extension?: boolean;
|
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> {
|
): 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,5 +1,6 @@
|
||||||
# Schema generated by Grats (https://grats.capt.dev)
|
# Schema generated by Grats (https://grats.capt.dev)
|
||||||
# Do not manually edit. Regenerate by running `npx grats`.
|
# 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.
|
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.
|
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.
|
has been uploaded under multiple names. Here we just pick one.
|
||||||
"""
|
"""
|
||||||
filename(
|
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 .
|
If true, the the correct file extension (.wsz or .wal) will be .
|
||||||
Otherwise, the original user-uploaded file extension will be used.
|
Otherwise, the original user-uploaded file extension will be used.
|
||||||
|
|
@ -164,6 +169,16 @@ type ArchiveFile {
|
||||||
url: String
|
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"""
|
"""A classic Winamp skin"""
|
||||||
type ClassicSkin implements Node & Skin {
|
type ClassicSkin implements Node & Skin {
|
||||||
"""List of files contained within the skin's .wsz archive"""
|
"""List of files contained within the skin's .wsz archive"""
|
||||||
|
|
@ -177,6 +192,10 @@ type ClassicSkin implements Node & Skin {
|
||||||
has been uploaded under multiple names. Here we just pick one.
|
has been uploaded under multiple names. Here we just pick one.
|
||||||
"""
|
"""
|
||||||
filename(
|
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 .
|
If true, the the correct file extension (.wsz or .wal) will be .
|
||||||
Otherwise, the original user-uploaded file extension will be used.
|
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.
|
has been uploaded under multiple names. Here we just pick one.
|
||||||
"""
|
"""
|
||||||
filename(
|
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 .
|
If true, the the correct file extension (.wsz or .wal) will be .
|
||||||
Otherwise, the original user-uploaded file extension will be used.
|
Otherwise, the original user-uploaded file extension will be used.
|
||||||
|
|
@ -384,6 +407,8 @@ type Mutation {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
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
|
Fetch archive file by it's MD5 hash
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
* Executable schema generated by Grats (https://grats.capt.dev)
|
* Executable schema generated by Grats (https://grats.capt.dev)
|
||||||
* Do not manually edit. Regenerate by running `npx grats`.
|
* 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_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_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";
|
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;
|
return awaited;
|
||||||
}
|
}
|
||||||
export function getSchema(): GraphQLSchema {
|
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({
|
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
|
||||||
name: "InternetArchiveItem",
|
name: "InternetArchiveItem",
|
||||||
fields() {
|
fields() {
|
||||||
|
|
@ -187,9 +258,13 @@ export function getSchema(): GraphQLSchema {
|
||||||
name: "filename",
|
name: "filename",
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
args: {
|
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: {
|
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.",
|
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),
|
type: new GraphQLNonNull(GraphQLBoolean),
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
}
|
}
|
||||||
|
|
@ -253,70 +328,33 @@ export function getSchema(): GraphQLSchema {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
|
||||||
name: "ArchiveFile",
|
name: "BulkDownloadConnection",
|
||||||
description: "A file found within a Winamp Skin's .wsz archive",
|
description: "Connection for bulk download skin metadata",
|
||||||
fields() {
|
fields() {
|
||||||
return {
|
return {
|
||||||
date: {
|
estimatedSizeBytes: {
|
||||||
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
description: "Estimated total size in bytes (approximation for progress indication)",
|
||||||
name: "date",
|
name: "estimatedSizeBytes",
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
resolve(source) {
|
resolve(source, args, context, info) {
|
||||||
return assertNonNull(source.getIsoDate());
|
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
file_md5: {
|
nodes: {
|
||||||
description: "The md5 hash of the file within the archive",
|
description: "List of skin metadata for bulk download",
|
||||||
name: "file_md5",
|
name: "nodes",
|
||||||
type: GraphQLString,
|
type: new GraphQLList(new GraphQLNonNull(SkinType)),
|
||||||
resolve(source) {
|
resolve(source, _args, context) {
|
||||||
return assertNonNull(source.getFileMd5());
|
return assertNonNull(source.nodes(getUserContext(context)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: {
|
totalCount: {
|
||||||
description: "Filename of the file within the archive",
|
description: "Total number of skins available for download",
|
||||||
name: "filename",
|
name: "totalCount",
|
||||||
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,
|
type: GraphQLInt,
|
||||||
resolve(source) {
|
resolve(source, args, context, info) {
|
||||||
return source.getFileSize();
|
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||||
}
|
|
||||||
},
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -382,9 +420,13 @@ export function getSchema(): GraphQLSchema {
|
||||||
name: "filename",
|
name: "filename",
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
args: {
|
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: {
|
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.",
|
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),
|
type: new GraphQLNonNull(GraphQLBoolean),
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
}
|
}
|
||||||
|
|
@ -545,9 +587,13 @@ export function getSchema(): GraphQLSchema {
|
||||||
name: "filename",
|
name: "filename",
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
args: {
|
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: {
|
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.",
|
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),
|
type: new GraphQLNonNull(GraphQLBoolean),
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
}
|
}
|
||||||
|
|
@ -903,13 +949,30 @@ export function getSchema(): GraphQLSchema {
|
||||||
name: "Query",
|
name: "Query",
|
||||||
fields() {
|
fields() {
|
||||||
return {
|
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: {
|
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.",
|
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",
|
name: "fetch_archive_file_by_md5",
|
||||||
type: ArchiveFileType,
|
type: ArchiveFileType,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -923,7 +986,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: InternetArchiveItemType,
|
type: InternetArchiveItemType,
|
||||||
args: {
|
args: {
|
||||||
identifier: {
|
identifier: {
|
||||||
name: "identifier",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -937,7 +999,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: SkinType,
|
type: SkinType,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -951,7 +1012,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: TweetType,
|
type: TweetType,
|
||||||
args: {
|
args: {
|
||||||
url: {
|
url: {
|
||||||
name: "url",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -973,12 +1033,10 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: ModernSkinsConnectionType,
|
type: ModernSkinsConnectionType,
|
||||||
args: {
|
args: {
|
||||||
first: {
|
first: {
|
||||||
name: "first",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 10
|
defaultValue: 10
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
name: "offset",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
}
|
}
|
||||||
|
|
@ -993,7 +1051,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: NodeType,
|
type: NodeType,
|
||||||
args: {
|
args: {
|
||||||
id: {
|
id: {
|
||||||
name: "id",
|
|
||||||
type: new GraphQLNonNull(GraphQLID)
|
type: new GraphQLNonNull(GraphQLID)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1007,17 +1064,14 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: new GraphQLList(ClassicSkinType),
|
type: new GraphQLList(ClassicSkinType),
|
||||||
args: {
|
args: {
|
||||||
first: {
|
first: {
|
||||||
name: "first",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 10
|
defaultValue: 10
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
name: "offset",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
name: "query",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1031,17 +1085,14 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: new GraphQLList(SkinType),
|
type: new GraphQLList(SkinType),
|
||||||
args: {
|
args: {
|
||||||
first: {
|
first: {
|
||||||
name: "first",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 10
|
defaultValue: 10
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
name: "offset",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
name: "query",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1063,21 +1114,17 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: SkinsConnectionType,
|
type: SkinsConnectionType,
|
||||||
args: {
|
args: {
|
||||||
filter: {
|
filter: {
|
||||||
name: "filter",
|
|
||||||
type: SkinsFilterOptionType
|
type: SkinsFilterOptionType
|
||||||
},
|
},
|
||||||
first: {
|
first: {
|
||||||
name: "first",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 10
|
defaultValue: 10
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
name: "offset",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
sort: {
|
sort: {
|
||||||
name: "sort",
|
|
||||||
type: SkinsSortOptionType
|
type: SkinsSortOptionType
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1099,17 +1146,14 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: TweetsConnectionType,
|
type: TweetsConnectionType,
|
||||||
args: {
|
args: {
|
||||||
first: {
|
first: {
|
||||||
name: "first",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 10
|
defaultValue: 10
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
name: "offset",
|
|
||||||
type: new GraphQLNonNull(GraphQLInt),
|
type: new GraphQLNonNull(GraphQLInt),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
sort: {
|
sort: {
|
||||||
name: "sort",
|
|
||||||
type: TweetsSortOptionType
|
type: TweetsSortOptionType
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1123,7 +1167,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: new GraphQLList(SkinUploadType),
|
type: new GraphQLList(SkinUploadType),
|
||||||
args: {
|
args: {
|
||||||
ids: {
|
ids: {
|
||||||
name: "ids",
|
|
||||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1138,7 +1181,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: new GraphQLList(SkinUploadType),
|
type: new GraphQLList(SkinUploadType),
|
||||||
args: {
|
args: {
|
||||||
md5s: {
|
md5s: {
|
||||||
name: "md5s",
|
|
||||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1205,7 +1247,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: new GraphQLList(UploadUrlType),
|
type: new GraphQLList(UploadUrlType),
|
||||||
args: {
|
args: {
|
||||||
files: {
|
files: {
|
||||||
name: "files",
|
|
||||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
|
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1219,11 +1260,9 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
id: {
|
id: {
|
||||||
name: "id",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
},
|
},
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1244,7 +1283,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1258,7 +1296,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1272,7 +1309,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1286,7 +1322,6 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
name: "md5",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1300,15 +1335,12 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
email: {
|
email: {
|
||||||
name: "email",
|
|
||||||
type: GraphQLString
|
type: GraphQLString
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
name: "message",
|
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
},
|
},
|
||||||
url: {
|
url: {
|
||||||
name: "url",
|
|
||||||
type: GraphQLString
|
type: GraphQLString
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1328,8 +1360,19 @@ export function getSchema(): GraphQLSchema {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return new 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,
|
query: QueryType,
|
||||||
mutation: MutationType,
|
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> {
|
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
p,
|
p,
|
||||||
new Promise<never>((resolve, reject) =>
|
new Promise<never>((_resolve, reject) => {
|
||||||
setTimeout(() => reject("timeout"), duration)
|
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 type { Metadata } from "next";
|
||||||
import { generateSkinPageMetadata } from "../skinMetadata";
|
import { generateSkinPageMetadata } from "../skinMetadata";
|
||||||
|
|
||||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
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 type { Metadata } from "next";
|
||||||
import { generateSkinPageMetadata } from "./skinMetadata";
|
import { generateSkinPageMetadata } from "./skinMetadata";
|
||||||
|
|
||||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <App />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import SkinModel from "../../../data/SkinModel";
|
import SkinModel from "../../../../data/SkinModel";
|
||||||
import UserContext from "../../../data/UserContext";
|
import UserContext from "../../../../data/UserContext";
|
||||||
|
|
||||||
export async function generateSkinPageMetadata(
|
export async function generateSkinPageMetadata(
|
||||||
hash: string
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -111,8 +111,8 @@ function SkinTableUnbound({
|
||||||
}
|
}
|
||||||
return skin ? skin.hash : `unfectched-index-${requestToken}`;
|
return skin ? skin.hash : `unfectched-index-${requestToken}`;
|
||||||
}
|
}
|
||||||
const gridRef = React.useRef();
|
const gridRef = React.useRef<any>(null);
|
||||||
const itemRef = React.useRef();
|
const itemRef = React.useRef<number>(0);
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (gridRef.current == null) {
|
if (gridRef.current == null) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
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();
|
return new UserContext();
|
||||||
},
|
},
|
||||||
logger: {
|
logger: {
|
||||||
log: (message: string, context: Record<string, any>) => {
|
log: (_message: string, _context: Record<string, any>) => {
|
||||||
console.log(message, context);
|
// console.log(message, context);
|
||||||
},
|
},
|
||||||
logError: (message: string, context: Record<string, any>) => {
|
logError: (message: string, context: Record<string, any>) => {
|
||||||
console.error(message, context);
|
console.error(message, context);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import App from "../App";
|
import App from "./App";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
|
|
@ -35,11 +35,14 @@ import { setHashesForSkin } from "./skinHash";
|
||||||
import * as S3 from "./s3";
|
import * as S3 from "./s3";
|
||||||
import { generateDescription } from "./services/openAi";
|
import { generateDescription } from "./services/openAi";
|
||||||
import KeyValue from "./data/KeyValue";
|
import KeyValue from "./data/KeyValue";
|
||||||
|
import { postToBluesky } from "./tasks/bluesky";
|
||||||
|
import { computeSkinRankings } from "./tasks/computeScrollRanking";
|
||||||
|
|
||||||
async function withHandler(
|
async function withHandler(
|
||||||
cb: (handler: DiscordEventHandler) => Promise<void>
|
cb: (handler: DiscordEventHandler) => Promise<void>
|
||||||
) {
|
) {
|
||||||
const handler = new DiscordEventHandler();
|
const handler = new DiscordEventHandler();
|
||||||
|
await handler._clientPromise; // Ensure client is initialized
|
||||||
try {
|
try {
|
||||||
await cb(handler);
|
await cb(handler);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -81,21 +84,30 @@ program
|
||||||
.argument("[md5]", "md5 of the skin to share")
|
.argument("[md5]", "md5 of the skin to share")
|
||||||
.option("-t, --twitter", "Share on Twitter")
|
.option("-t, --twitter", "Share on Twitter")
|
||||||
.option("-i, --instagram", "Share on Instagram")
|
.option("-i, --instagram", "Share on Instagram")
|
||||||
|
.option("-b, --bluesky", "Share on Bluesky")
|
||||||
.option("-m, --mastodon", "Share on Mastodon")
|
.option("-m, --mastodon", "Share on Mastodon")
|
||||||
.action(async (md5, { twitter, instagram, mastodon }) => {
|
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
|
||||||
if (!twitter && !instagram && !mastodon) {
|
|
||||||
throw new Error("Expected at least one of --twitter or --instagram");
|
|
||||||
}
|
|
||||||
await withDiscordClient(async (client) => {
|
await withDiscordClient(async (client) => {
|
||||||
if (twitter) {
|
if (twitter) {
|
||||||
await tweet(client, md5);
|
await tweet(client, md5);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (instagram) {
|
if (instagram) {
|
||||||
await insta(client, md5);
|
await insta(client, md5);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (mastodon) {
|
if (mastodon) {
|
||||||
await postToMastodon(client, md5);
|
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("====================================");
|
console.log("====================================");
|
||||||
}
|
}
|
||||||
if (purge) {
|
if (purge) {
|
||||||
// cat purge | xargs -I {} yarn cli skin --purge {}
|
// cat purge | xargs -I {} pnpm cli skin --purge {}
|
||||||
await Skins.deleteSkin(md5);
|
await Skins.deleteSkin(md5);
|
||||||
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
|
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
|
||||||
const purged = new Set(purgedArr);
|
const purged = new Set(purgedArr);
|
||||||
|
|
@ -298,6 +310,14 @@ program
|
||||||
console.table([await Skins.getStats()]);
|
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
|
program
|
||||||
.command("process-uploads")
|
.command("process-uploads")
|
||||||
.description("Process any unprocessed user uploads.")
|
.description("Process any unprocessed user uploads.")
|
||||||
|
|
@ -438,7 +458,7 @@ program
|
||||||
);
|
);
|
||||||
const md5s = rows.map((row) => row.md5);
|
const md5s = rows.map((row) => row.md5);
|
||||||
console.log(md5s.length);
|
console.log(md5s.length);
|
||||||
console.log(await Skins.updateSearchIndexs(ctx, md5s));
|
console.log(await Skins.updateSearchIndexes(ctx, md5s));
|
||||||
}
|
}
|
||||||
if (refreshContentHash) {
|
if (refreshContentHash) {
|
||||||
const ctx = new UserContext();
|
const ctx = new UserContext();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
|
||||||
// Used for session encryption
|
// Used for session encryption
|
||||||
export const SECRET = env("SECRET");
|
export const SECRET = env("SECRET");
|
||||||
export const NODE_ENV = env("NODE_ENV") || "production";
|
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 {
|
function env(key: string): string {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
|
|
|
||||||
29
packages/skin-database/data/SessionModel.ts
Normal file
29
packages/skin-database/data/SessionModel.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { knex } from "../db";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export default class SessionModel {
|
||||||
|
static async create(): Promise<string> {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const startTime = Date.now();
|
||||||
|
await knex("session").insert({
|
||||||
|
id: sessionId,
|
||||||
|
start_time: startTime,
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
|
||||||
|
await knex("session_skin").insert({
|
||||||
|
session_id: sessionId,
|
||||||
|
skin_md5: skinMd5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getIncludedSkinCount(sessionId: string): Promise<number> {
|
||||||
|
const result = await knex("session_skin")
|
||||||
|
.where({ session_id: sessionId })
|
||||||
|
.count("* as count")
|
||||||
|
.first();
|
||||||
|
return result ? (result.count as number) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -158,7 +158,7 @@ export default class SkinModel {
|
||||||
return "UNREVIEWED";
|
return "UNREVIEWED";
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFileName(): Promise<string> {
|
async getFileName(normalizeExtension?: boolean): Promise<string> {
|
||||||
const files = await this.getFiles();
|
const files = await this.getFiles();
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
|
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
|
||||||
|
|
@ -167,6 +167,9 @@ export default class SkinModel {
|
||||||
if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) {
|
if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) {
|
||||||
throw new Error("Expected filename to end with zip, wsz or wal.");
|
throw new Error("Expected filename to end with zip, wsz or wal.");
|
||||||
}
|
}
|
||||||
|
if (normalizeExtension) {
|
||||||
|
return path.parse(filename).name + ".wsz";
|
||||||
|
}
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,9 +196,6 @@ export default class SkinModel {
|
||||||
const filename = file.getFileName();
|
const filename = file.getFileName();
|
||||||
const isReadme = IS_README.test(filename);
|
const isReadme = IS_README.test(filename);
|
||||||
const isNotReadme = IS_NOT_README.test(filename);
|
const isNotReadme = IS_NOT_README.test(filename);
|
||||||
|
|
||||||
console.log({ filename, isReadme, isNotReadme, md5: file.getFileMd5() });
|
|
||||||
|
|
||||||
return isReadme && !isNotReadme;
|
return isReadme && !isNotReadme;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -232,8 +232,8 @@ export default class SkinModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAverageColor(): string {
|
getAverageColor(): string | null {
|
||||||
return this.row.average_color;
|
return this.row.average_color ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getBuffer = mem(async (): Promise<Buffer> => {
|
getBuffer = mem(async (): Promise<Buffer> => {
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,17 @@ export async function markAsPostedToMastodon(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markAsPostedToBlueSky(
|
||||||
|
md5: string,
|
||||||
|
postId: string,
|
||||||
|
url: string
|
||||||
|
): Promise<void> {
|
||||||
|
await knex("bluesky_posts").insert(
|
||||||
|
{ skin_md5: md5, post_id: postId, url },
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Also path actor
|
// TODO: Also path actor
|
||||||
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
|
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
|
||||||
const index = { objectID: md5, nsfw: true };
|
const index = { objectID: md5, nsfw: true };
|
||||||
|
|
@ -243,7 +254,7 @@ async function getSearchIndexes(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSearchIndexs(
|
export async function updateSearchIndexes(
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
md5s: string[]
|
md5s: string[]
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
|
@ -265,7 +276,7 @@ export async function updateSearchIndex(
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
md5: string
|
md5: string
|
||||||
): Promise<any | null> {
|
): Promise<any | null> {
|
||||||
return updateSearchIndexs(ctx, [md5]);
|
return updateSearchIndexes(ctx, [md5]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hideSkin(md5: string): Promise<void> {
|
export async function hideSkin(md5: string): Promise<void> {
|
||||||
|
|
@ -400,6 +411,7 @@ export async function getErroredUpload(): Promise<{
|
||||||
.where("status", "ERRORED")
|
.where("status", "ERRORED")
|
||||||
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
|
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
|
||||||
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
|
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
|
||||||
|
.where("skin_md5", "!=", "a418fd00583006b6e79cf0b251c43771")
|
||||||
.first(["skin_md5", "id", "filename"]);
|
.first(["skin_md5", "id", "filename"]);
|
||||||
return found || null;
|
return found || null;
|
||||||
}
|
}
|
||||||
|
|
@ -550,6 +562,31 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
|
||||||
return skin.md5;
|
return skin.md5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSkinToPostToBluesky(): Promise<string | null> {
|
||||||
|
// TODO: This does not account for skins that have been both approved and rejected
|
||||||
|
const postables = await knex("skins")
|
||||||
|
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
|
||||||
|
.leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5")
|
||||||
|
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
|
||||||
|
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
|
||||||
|
.where({
|
||||||
|
"bluesky_posts.id": null,
|
||||||
|
skin_type: 1,
|
||||||
|
"skin_reviews.review": "APPROVED",
|
||||||
|
"refreshes.error": null,
|
||||||
|
})
|
||||||
|
.where("likes", ">", 10)
|
||||||
|
.groupBy("skins.md5")
|
||||||
|
.orderByRaw("random()")
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const skin = postables[0];
|
||||||
|
if (skin == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return skin.md5;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUnreviewedSkinCount(): Promise<number> {
|
export async function getUnreviewedSkinCount(): Promise<number> {
|
||||||
const rows = await knex("skins")
|
const rows = await knex("skins")
|
||||||
.where({ skin_type: 1 })
|
.where({ skin_type: 1 })
|
||||||
|
|
@ -682,6 +719,53 @@ export type MuseumPage = Array<{
|
||||||
nsfw: boolean;
|
nsfw: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ScrollPage = Array<{
|
||||||
|
md5: string;
|
||||||
|
}>;
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
|
||||||
|
|
||||||
|
const randomCount = Math.floor(PAGE_SIZE * FRESHNESS_PERCENTAGE);
|
||||||
|
const curatedCount = PAGE_SIZE - randomCount;
|
||||||
|
|
||||||
|
export async function getScrollPage(sessionId: string): Promise<ScrollPage> {
|
||||||
|
const skins = await knex.raw(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
museum_sort_order.skin_md5
|
||||||
|
FROM
|
||||||
|
museum_sort_order
|
||||||
|
WHERE museum_sort_order.skin_md5 NOT IN (SELECT skin_md5 from session_skin WHERE session_id = ?)
|
||||||
|
LIMIT ?`,
|
||||||
|
[sessionId, curatedCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const randomSkins = await knex.raw(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
skins.md5 as skin_md5
|
||||||
|
FROM
|
||||||
|
skins
|
||||||
|
LEFT JOIN session_skin ON session_skin.skin_md5 = skins.md5 AND session_skin.session_id = ?
|
||||||
|
LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = 'NSFW'
|
||||||
|
WHERE
|
||||||
|
skins.skin_type = 1 AND session_skin.skin_md5 IS NULL AND skin_reviews.skin_md5 IS NULL
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?`,
|
||||||
|
[sessionId, randomCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: Technically we could get duplicates if a random skin is also in museum_sort_order,
|
||||||
|
// but in practice this is rare and acceptable for the use case.
|
||||||
|
const allSkins = skins.concat(randomSkins);
|
||||||
|
// Shuffle the results
|
||||||
|
allSkins.sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
return allSkins.map(({ skin_md5 }) => {
|
||||||
|
return { md5: skin_md5 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMuseumPage({
|
export async function getMuseumPage({
|
||||||
offset,
|
offset,
|
||||||
first,
|
first,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// To toggle between blue and green deployments:
|
|
||||||
// sudo vim /etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf
|
|
||||||
// Update port number
|
|
||||||
// sudo systemctl reload apache2
|
|
||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: "skin-database-blue",
|
|
||||||
script: "yarn",
|
|
||||||
interpreter: "bash",
|
|
||||||
args: "start",
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 3001,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skin-database-green",
|
|
||||||
script: "yarn",
|
|
||||||
interpreter: "bash",
|
|
||||||
args: "start",
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 3002,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -31,3 +31,5 @@ process.env.INSTAGRAM_ACCESS_TOKEN = "<DUMMY>";
|
||||||
process.env.INSTAGRAM_ACCOUNT_ID = "<DUMMY>";
|
process.env.INSTAGRAM_ACCOUNT_ID = "<DUMMY>";
|
||||||
process.env.MASTODON_ACCESS_TOKEN = "<DUMMY>";
|
process.env.MASTODON_ACCESS_TOKEN = "<DUMMY>";
|
||||||
process.env.SECRET = "<DUMMY>";
|
process.env.SECRET = "<DUMMY>";
|
||||||
|
process.env.BLUESKY_PASSWORD = "<DUMMY>";
|
||||||
|
process.env.BLUESKY_USERNAME = "<DUMMY>";
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect, useSelector } from "react-redux";
|
||||||
import About from "./About";
|
import About from "./About";
|
||||||
import Feedback from "./Feedback";
|
import Feedback from "./Feedback";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import Overlay from "./Overlay";
|
import Overlay from "./Overlay";
|
||||||
import SkinTable from "./SkinTable";
|
import SkinTable from "./SkinTable";
|
||||||
import FocusedSkin from "./FocusedSkin";
|
import FocusedSkin from "./FocusedSkin";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import * as Selectors from "./redux/selectors";
|
import * as Selectors from "./redux/selectors";
|
||||||
import * as Actions from "./redux/actionCreators";
|
import * as Actions from "./redux/actionCreators";
|
||||||
import { ABOUT_PAGE, REVIEW_PAGE } from "./constants";
|
import {
|
||||||
|
ABOUT_PAGE,
|
||||||
|
REVIEW_PAGE,
|
||||||
|
SCREENSHOT_WIDTH,
|
||||||
|
SKIN_RATIO,
|
||||||
|
} from "./constants";
|
||||||
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
|
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
|
||||||
import { SCREENSHOT_WIDTH, SKIN_RATIO } from "./constants";
|
|
||||||
import UploadGrid from "./upload/UploadGrid";
|
import UploadGrid from "./upload/UploadGrid";
|
||||||
import Metadata from "./components/Metadata";
|
import Metadata from "./components/Metadata";
|
||||||
import SkinReadme from "./SkinReadme";
|
import SkinReadme from "./SkinReadme";
|
||||||
|
|
@ -78,6 +81,7 @@ function App(props) {
|
||||||
windowWidth={windowWidthWithScrollabar}
|
windowWidth={windowWidthWithScrollabar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary -- legacy code */}
|
||||||
{props.showFeedbackForm ? (
|
{props.showFeedbackForm ? (
|
||||||
<Overlay>
|
<Overlay>
|
||||||
<Feedback />
|
<Feedback />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { getUrl } from "./redux/selectors";
|
import { getUrl } from "./redux/selectors";
|
||||||
import * as Actions from "./redux/actionCreators";
|
import * as Actions from "./redux/actionCreators";
|
||||||
import { useActionCreator } from "./hooks";
|
import { useActionCreator } from "./hooks";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { fetchGraphql, gql } from "./utils";
|
import { fetchGraphql, gql } from "./utils";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as Utils from "./utils";
|
import * as Utils from "./utils";
|
||||||
|
import { gql } from "./utils";
|
||||||
import { algoliasearch } from "algoliasearch";
|
import { algoliasearch } from "algoliasearch";
|
||||||
|
|
||||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||||
|
|
@ -6,14 +7,15 @@ const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||||
// Fallback search that uses SQLite. Useful for when we've exceeded the Algolia
|
// Fallback search that uses SQLite. Useful for when we've exceeded the Algolia
|
||||||
// search quota.
|
// search quota.
|
||||||
export async function graphqlSearch(query) {
|
export async function graphqlSearch(query) {
|
||||||
const queryText = Utils.gql`
|
const queryText = gql`
|
||||||
query SearchQuery($query: String!) {
|
query SearchQuery($query: String!) {
|
||||||
search_classic_skins(query: $query, first: 500) {
|
search_classic_skins(query: $query, first: 500) {
|
||||||
filename
|
filename(normalize_extension: true)
|
||||||
md5
|
md5
|
||||||
nsfw
|
nsfw
|
||||||
}
|
}
|
||||||
}`;
|
}
|
||||||
|
`;
|
||||||
const data = await Utils.fetchGraphql(queryText, { query });
|
const data = await Utils.fetchGraphql(queryText, { query });
|
||||||
const hits = data.search_classic_skins.map((skin) => {
|
const hits = data.search_classic_skins.map((skin) => {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import React, { useLayoutEffect, useState } from "react";
|
||||||
function DownloadText({ text, children, ...restProps }) {
|
function DownloadText({ text, children, ...restProps }) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
var blob = new Blob([text], {
|
let blob = new Blob([text], {
|
||||||
type: "text/plain;charset=utf-8",
|
type: "text/plain;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export const SCREENSHOT_WIDTH = 275;
|
export const SCREENSHOT_WIDTH = 275;
|
||||||
export const SCREENSHOT_HEIGHT = 348;
|
export const SCREENSHOT_HEIGHT = 348;
|
||||||
export const SKIN_RATIO = SCREENSHOT_HEIGHT / SCREENSHOT_WIDTH;
|
export const SKIN_RATIO = SCREENSHOT_HEIGHT / SCREENSHOT_WIDTH;
|
||||||
|
export const MOBILE_MAX_WIDTH = "56.25vh"; // 9:16 aspect ratio (100vh * 9/16) for TikTok-style scroll
|
||||||
export const ABOUT_PAGE = "ABOUT_PAGE";
|
export const ABOUT_PAGE = "ABOUT_PAGE";
|
||||||
export const UPLOAD_PAGE = "UPLOAD_PAGE";
|
export const UPLOAD_PAGE = "UPLOAD_PAGE";
|
||||||
export const REVIEW_PAGE = "REVIEW_PAGE";
|
export const REVIEW_PAGE = "REVIEW_PAGE";
|
||||||
|
|
@ -32,9 +33,8 @@ export const SKIN_CDN = R2_CDN;
|
||||||
// export const SCREENSHOT_CDN = "https://cdn.webampskins.org";
|
// export const SCREENSHOT_CDN = "https://cdn.webampskins.org";
|
||||||
// export const SKIN_CDN = "https://cdn.webampskins.org";
|
// export const SKIN_CDN = "https://cdn.webampskins.org";
|
||||||
|
|
||||||
// Note: This is a Cloudflare proxy for api.webamp.org which
|
// Sites have been unified, we can point to ourselves now
|
||||||
// provides some additional caching.
|
export const API_URL = "https://skins.webamp.org";
|
||||||
export const API_URL = "https://api.webampskins.org";
|
|
||||||
// export const API_URL = "https://dev.webamp.org";
|
// export const API_URL = "https://dev.webamp.org";
|
||||||
export const HEADING_HEIGHT = 46;
|
export const HEADING_HEIGHT = 46;
|
||||||
export const CHUNK_SIZE = 300;
|
export const CHUNK_SIZE = 300;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import SparkMD5 from "spark-md5";
|
import SparkMD5 from "spark-md5";
|
||||||
|
|
||||||
export function hashFile(file) {
|
export function hashFile(file) {
|
||||||
var blobSlice =
|
let blobSlice =
|
||||||
File.prototype.slice ||
|
File.prototype.slice ||
|
||||||
File.prototype.mozSlice ||
|
File.prototype.mozSlice ||
|
||||||
File.prototype.webkitSlice;
|
File.prototype.webkitSlice;
|
||||||
|
|
@ -26,7 +26,7 @@ export function hashFile(file) {
|
||||||
fileReader.onerror = reject;
|
fileReader.onerror = reject;
|
||||||
|
|
||||||
function loadNext() {
|
function loadNext() {
|
||||||
var start = currentChunk * chunkSize,
|
let start = currentChunk * chunkSize,
|
||||||
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
|
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
|
||||||
|
|
||||||
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ const unloadedSkinEpic = (actions, _states) =>
|
||||||
count
|
count
|
||||||
nodes {
|
nodes {
|
||||||
md5
|
md5
|
||||||
filename
|
filename(normalize_extension: true)
|
||||||
nsfw
|
nsfw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -462,7 +462,10 @@ const urlEpic = (actions, state) => {
|
||||||
|
|
||||||
const newUrl = proposedUrl.toString();
|
const newUrl = proposedUrl.toString();
|
||||||
|
|
||||||
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
// Avoid clobbering URL for beta site
|
||||||
|
if (!window.location.href.includes("/scroll")) {
|
||||||
|
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
ignoreElements()
|
ignoreElements()
|
||||||
);
|
);
|
||||||
|
|
@ -481,7 +484,7 @@ const skinDataEpic = (actions, state) => {
|
||||||
const QUERY = gql`
|
const QUERY = gql`
|
||||||
query IndividualSkin($md5: String!) {
|
query IndividualSkin($md5: String!) {
|
||||||
fetch_skin_by_md5(md5: $md5) {
|
fetch_skin_by_md5(md5: $md5) {
|
||||||
filename
|
filename(normalize_extension: true)
|
||||||
nsfw
|
nsfw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ function Row({ name, loading, right, complete }) {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
// eslint-disable-next-line no-nested-ternary -- legacy code
|
||||||
width: loading ? `90%` : complete ? `100%` : `0%`,
|
width: loading ? `90%` : complete ? `100%` : `0%`,
|
||||||
transitionProperty: "all",
|
transitionProperty: "all",
|
||||||
// TODO: Try to learn how long it really takes
|
// TODO: Try to learn how long it really takes
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ export async function upload(fileObj) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Request to ${uploadUrl} returned 503, going to retry again in 5 seconds. ${retries} retries left...`
|
`Request to ${uploadUrl} returned 503, going to retry again in 5 seconds. ${retries} retries left...`
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function museumUrlFromHash(hash) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowSize() {
|
export function getWindowSize() {
|
||||||
var w = window,
|
let w = window,
|
||||||
d = document,
|
d = document,
|
||||||
e = d.documentElement,
|
e = d.documentElement,
|
||||||
g = d.getElementsByTagName("body")[0],
|
g = d.getElementsByTagName("body")[0],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import * as Knex from "knex";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<any> {
|
||||||
|
await knex.raw(
|
||||||
|
`CREATE TABLE "bluesky_posts" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
|
||||||
|
"skin_md5" TEXT NOT NULL,
|
||||||
|
post_id text NOT NULL UNIQUE,
|
||||||
|
url text NOT NULL UNIQUE
|
||||||
|
);`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<any> {
|
||||||
|
await knex.raw(`DROP TABLE "bluesky_posts"`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as Knex from "knex";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.raw(`
|
||||||
|
CREATE TABLE user_log_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
metadata TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_session_id ON user_log_events(session_id);
|
||||||
|
CREATE INDEX idx_timestamp ON user_log_events(timestamp);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.raw(`
|
||||||
|
DROP TABLE IF EXISTS user_log_events;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import * as Knex from "knex";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.raw(`
|
||||||
|
CREATE TABLE session (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
start_time INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await knex.raw(`
|
||||||
|
CREATE TABLE session_skin (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
skin_md5 TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES session(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await knex.raw(`
|
||||||
|
CREATE INDEX idx_session_skin_session_id ON session_skin(session_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await knex.raw(`
|
||||||
|
CREATE INDEX idx_session_skin_skin_md5 ON session_skin(skin_md5);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.raw(`
|
||||||
|
DROP TABLE IF EXISTS session_skin;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await knex.raw(`
|
||||||
|
DROP TABLE IF EXISTS session;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
typescript: {
|
||||||
|
// !! WARN !!
|
||||||
|
// Dangerously allow production builds to successfully complete even if
|
||||||
|
// your project has type errors.
|
||||||
|
// !! WARN !!
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"],
|
serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"],
|
||||||
|
experimental: {
|
||||||
|
viewTransition: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atproto/api": "^0.17.2",
|
||||||
"@next/third-parties": "^15.3.3",
|
"@next/third-parties": "^15.3.3",
|
||||||
"@sentry/node": "^5.27.3",
|
"@sentry/node": "^5.27.3",
|
||||||
"@sentry/tracing": "^5.27.3",
|
"@sentry/tracing": "^5.27.3",
|
||||||
|
|
@ -24,9 +25,10 @@
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"knex": "^0.21.1",
|
"knex": "^0.21.1",
|
||||||
"lru-cache": "^6.0.0",
|
"lru-cache": "^6.0.0",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"mastodon-api": "^1.3.0",
|
"mastodon-api": "^1.3.0",
|
||||||
"md5": "^2.2.1",
|
"md5": "^2.2.1",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.6",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"openai": "^4.68.0",
|
"openai": "^4.68.0",
|
||||||
"polygon-clipping": "^0.15.3",
|
"polygon-clipping": "^0.15.3",
|
||||||
|
|
@ -46,7 +48,8 @@
|
||||||
"ts-node": "^10.5.0",
|
"ts-node": "^10.5.0",
|
||||||
"twit": "^2.2.11",
|
"twit": "^2.2.11",
|
||||||
"winston": "^3.2.1",
|
"winston": "^3.2.1",
|
||||||
"yargs": "^13.2.4"
|
"yargs": "^13.2.4",
|
||||||
|
"webamp": "workspace:*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
@ -73,9 +76,10 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/lru-cache": "^5.1.0",
|
"@types/lru-cache": "^5.1.0",
|
||||||
"@types/node-fetch": "^2.5.7",
|
"@types/node-fetch": "^2.5.7",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
||||||
"@typescript-eslint/parser": "^8.36.0",
|
"@typescript-eslint/parser": "^8.36.0",
|
||||||
"grats": "^0.0.31",
|
"grats": "0.0.0-main-e655d1ae",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|
|
||||||
304
packages/skin-database/scripts/deploy.ts
Normal file
304
packages/skin-database/scripts/deploy.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* Simple Blue/Green Deployment Script
|
||||||
|
* This script handles deploying to the inactive instance and switching traffic
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/deploy.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { readFileSync, copyFileSync } from "fs";
|
||||||
|
import * as readline from "readline";
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
red: "\x1b[0;31m",
|
||||||
|
green: "\x1b[0;32m",
|
||||||
|
blue: "\x1b[0;34m",
|
||||||
|
cyan: "\x1b[0;36m",
|
||||||
|
yellow: "\x1b[1;33m",
|
||||||
|
bold: "\x1b[1m",
|
||||||
|
reset: "\x1b[0m",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const APACHE_CONFIG = "/etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf";
|
||||||
|
const BLUE_PORT = 3001;
|
||||||
|
const GREEN_PORT = 3002;
|
||||||
|
|
||||||
|
type Color = "blue" | "green";
|
||||||
|
|
||||||
|
interface DeploymentState {
|
||||||
|
currentColor: Color;
|
||||||
|
currentPort: number;
|
||||||
|
newColor: Color;
|
||||||
|
newPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message: string, color?: keyof typeof colors): void {
|
||||||
|
if (color) {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logBlank(): void {
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(command: string, description: string): void {
|
||||||
|
try {
|
||||||
|
execSync(command, { stdio: "inherit", shell: "/bin/bash" });
|
||||||
|
} catch (error) {
|
||||||
|
log(`✗ Failed to ${description}`, "red");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function execSilent(command: string): string {
|
||||||
|
return execSync(command, { encoding: "utf8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCurrentDeployment(): DeploymentState {
|
||||||
|
log("→ Detecting current active deployment...", "cyan");
|
||||||
|
|
||||||
|
const apacheConfig = readFileSync(APACHE_CONFIG, "utf8");
|
||||||
|
const isBlueActive = apacheConfig.includes(`localhost:${BLUE_PORT}`);
|
||||||
|
|
||||||
|
if (isBlueActive) {
|
||||||
|
log(
|
||||||
|
` Current active: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
` Deploying to: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
|
||||||
|
);
|
||||||
|
logBlank();
|
||||||
|
return {
|
||||||
|
currentColor: "blue",
|
||||||
|
currentPort: BLUE_PORT,
|
||||||
|
newColor: "green",
|
||||||
|
newPort: GREEN_PORT,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
` Current active: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
` Deploying to: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
|
||||||
|
);
|
||||||
|
logBlank();
|
||||||
|
return {
|
||||||
|
currentColor: "green",
|
||||||
|
currentPort: GREEN_PORT,
|
||||||
|
newColor: "blue",
|
||||||
|
newPort: BLUE_PORT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForConfirmation(
|
||||||
|
newPort: number,
|
||||||
|
newColor: Color
|
||||||
|
): Promise<boolean> {
|
||||||
|
const colorCode = newColor === "blue" ? colors.blue : colors.green;
|
||||||
|
log("========================================", "cyan");
|
||||||
|
log(" MANUAL VALIDATION REQUIRED", "yellow");
|
||||||
|
log("========================================", "cyan");
|
||||||
|
logBlank();
|
||||||
|
log(` Test the new ${colorCode}${newColor}${colors.reset} deployment at:`);
|
||||||
|
log(` ${colors.bold}https://${newColor}.api.webamp.org${colors.reset}`);
|
||||||
|
logBlank();
|
||||||
|
log(` You can test it with:`);
|
||||||
|
log(
|
||||||
|
` ${colors.cyan}curl -I https://${newColor}.api.webamp.org${colors.reset}`
|
||||||
|
);
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(" Does everything look good? (yes/no): ", (answer) => {
|
||||||
|
rl.close();
|
||||||
|
logBlank();
|
||||||
|
resolve(answer.toLowerCase() === "yes");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchApacheConfig(state: DeploymentState): void {
|
||||||
|
const colorCode = state.newColor === "blue" ? colors.blue : colors.green;
|
||||||
|
log(
|
||||||
|
`→ Switching production to ${colorCode}${state.newColor}${colors.reset}...`,
|
||||||
|
"cyan"
|
||||||
|
);
|
||||||
|
log(" Updating Apache configuration...", "cyan");
|
||||||
|
|
||||||
|
// Backup current config
|
||||||
|
const backupPath = `${APACHE_CONFIG}.backup`;
|
||||||
|
copyFileSync(APACHE_CONFIG, backupPath);
|
||||||
|
|
||||||
|
// Update the port in Apache config
|
||||||
|
exec(
|
||||||
|
`sudo sed -i 's/localhost:${state.currentPort}/localhost:${state.newPort}/g' "${APACHE_CONFIG}"`,
|
||||||
|
"update Apache configuration"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload Apache
|
||||||
|
exec("sudo systemctl reload apache2", "reload Apache");
|
||||||
|
|
||||||
|
log("✓ Apache configuration updated and reloaded", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Verify the change
|
||||||
|
const updatedConfig = readFileSync(APACHE_CONFIG, "utf8");
|
||||||
|
if (!updatedConfig.includes(`localhost:${state.newPort}`)) {
|
||||||
|
throw new Error("Configuration update verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBackup(): void {
|
||||||
|
log(" Restoring backup...", "yellow");
|
||||||
|
const backupPath = `${APACHE_CONFIG}.backup`;
|
||||||
|
exec(`sudo cp "${backupPath}" "${APACHE_CONFIG}"`, "restore backup");
|
||||||
|
exec("sudo systemctl reload apache2", "reload Apache");
|
||||||
|
log("✓ Backup restored", "yellow");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
try {
|
||||||
|
log("========================================", "cyan");
|
||||||
|
log(" Blue/Green Deployment Script", "cyan");
|
||||||
|
log("========================================", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 1: Detect current deployment
|
||||||
|
const state = detectCurrentDeployment();
|
||||||
|
|
||||||
|
// Step 2: Pull from GitHub
|
||||||
|
log("→ Pulling latest code from GitHub...", "cyan");
|
||||||
|
exec("git pull --rebase origin master", "pull from GitHub");
|
||||||
|
log("✓ Code updated", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 3: Ensure correct Node version
|
||||||
|
log("→ Ensuring correct Node version...", "cyan");
|
||||||
|
exec(
|
||||||
|
"source ~/.nvm/nvm.sh && nvm install",
|
||||||
|
"ensure correct Node version from .nvmrc"
|
||||||
|
);
|
||||||
|
log("✓ Node version verified", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 4: Install dependencies
|
||||||
|
log("→ Installing dependencies...", "cyan");
|
||||||
|
exec(
|
||||||
|
"source ~/.nvm/nvm.sh && nvm exec yarn install --frozen-lockfile",
|
||||||
|
"install dependencies"
|
||||||
|
);
|
||||||
|
log("✓ Dependencies installed", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 5: Build the site
|
||||||
|
log("→ Building the site...", "cyan");
|
||||||
|
exec("source ~/.nvm/nvm.sh && nvm exec yarn build", "build the site");
|
||||||
|
log("✓ Build complete", "cyan");
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 5: Deploy to inactive instance
|
||||||
|
const newColorCode = state.newColor === "blue" ? colors.blue : colors.green;
|
||||||
|
log(
|
||||||
|
`→ Restarting ${newColorCode}${state.newColor}${colors.reset} instance...`,
|
||||||
|
"cyan"
|
||||||
|
);
|
||||||
|
exec(
|
||||||
|
`pm2 restart skin-database-${state.newColor}`,
|
||||||
|
`restart ${state.newColor} instance`
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
`✓ ${newColorCode}${state.newColor}${colors.reset} instance restarted`,
|
||||||
|
"cyan"
|
||||||
|
);
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Wait for the service to start
|
||||||
|
log("→ Waiting for service to be ready...", "cyan");
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the service is running
|
||||||
|
const pm2List = execSilent("pm2 list");
|
||||||
|
const isRunning =
|
||||||
|
pm2List.includes(`skin-database-${state.newColor}`) &&
|
||||||
|
pm2List.includes("online");
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
log(
|
||||||
|
`✓ ${newColorCode}${state.newColor}${colors.reset} instance is running`,
|
||||||
|
"cyan"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`✗ ${newColorCode}${state.newColor}${colors.reset} instance failed to start!`,
|
||||||
|
"red"
|
||||||
|
);
|
||||||
|
log(` Check PM2 logs: pm2 logs skin-database-${state.newColor}`, "red");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
logBlank();
|
||||||
|
|
||||||
|
// Step 6: Manual validation prompt
|
||||||
|
const confirmed = await promptForConfirmation(
|
||||||
|
state.newPort,
|
||||||
|
state.newColor
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
log("✗ Deployment cancelled!", "red");
|
||||||
|
log(
|
||||||
|
` The ${newColorCode}${state.newColor}${colors.reset} instance is running but not active in production.`
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
` You can rollback by restarting: pm2 restart skin-database-${state.newColor}`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Switch Apache configuration
|
||||||
|
switchApacheConfig(state);
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
const currentColorCode =
|
||||||
|
state.currentColor === "blue" ? colors.blue : colors.green;
|
||||||
|
log("========================================", "cyan");
|
||||||
|
log(" DEPLOYMENT SUCCESSFUL!", "cyan");
|
||||||
|
log("========================================", "cyan");
|
||||||
|
logBlank();
|
||||||
|
log(
|
||||||
|
` Active deployment: ${newColorCode}${state.newColor}${colors.reset} (port ${state.newPort})`
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
` Previous deployment: ${currentColorCode}${state.currentColor}${colors.reset} (port ${state.currentPort}) - still running as backup`
|
||||||
|
);
|
||||||
|
logBlank();
|
||||||
|
log("Note: If you need to rollback:", "yellow");
|
||||||
|
log(` 1. Edit: ${APACHE_CONFIG}`);
|
||||||
|
log(` 2. Change port back to ${state.currentPort}`);
|
||||||
|
log(` 3. Run: sudo systemctl reload apache2`);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === "Configuration update verification failed"
|
||||||
|
) {
|
||||||
|
log("✗ Configuration update failed!", "red");
|
||||||
|
restoreBackup();
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the deployment
|
||||||
|
main();
|
||||||
|
|
@ -7,10 +7,10 @@ source "$NVM_DIR/nvm.sh"
|
||||||
nvm use 20
|
nvm use 20
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
yarn install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Build the site
|
# Build the site
|
||||||
yarn run build
|
pnpm run build
|
||||||
|
|
||||||
# Reload processes via PM2
|
# Reload processes via PM2
|
||||||
pm2 reload ecosystem.config.js
|
pm2 reload ecosystem.config.js
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { exec } from "../utils";
|
import { execFile } from "../utils";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Path to the ia command in the virtual environment
|
||||||
|
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
|
||||||
|
|
||||||
|
// Environment variables for the virtual environment
|
||||||
|
const getVenvEnv = () => ({
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
|
||||||
|
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
|
||||||
|
});
|
||||||
|
|
||||||
export async function fetchMetadata(identifier: string): Promise<any> {
|
export async function fetchMetadata(identifier: string): Promise<any> {
|
||||||
const r = await fetch(`https://archive.org/metadata/${identifier}`);
|
const r = await fetch(`https://archive.org/metadata/${identifier}`);
|
||||||
|
|
@ -11,9 +22,8 @@ export async function fetchMetadata(identifier: string): Promise<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTasks(identifier: string): Promise<any> {
|
export async function fetchTasks(identifier: string): Promise<any> {
|
||||||
const command = `ia tasks ${identifier}`;
|
const result = await execFile(IA_COMMAND, ["tasks", identifier], {
|
||||||
const result = await exec(command, {
|
env: getVenvEnv(),
|
||||||
encoding: "utf8",
|
|
||||||
});
|
});
|
||||||
return result.stdout
|
return result.stdout
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -25,12 +35,31 @@ export async function uploadFile(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
filepath: string
|
filepath: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const command = `ia upload ${identifier} "${filepath}"`;
|
await execFile(IA_COMMAND, ["upload", identifier, filepath], {
|
||||||
await exec(command, { encoding: "utf8" });
|
env: getVenvEnv(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFiles(
|
||||||
|
identifier: string,
|
||||||
|
filepaths: string[],
|
||||||
|
metadata?: { [key: string]: string }
|
||||||
|
): Promise<any> {
|
||||||
|
const args = ["upload", identifier, ...filepaths];
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
Object.entries(metadata).forEach(([key, value]) => {
|
||||||
|
args.push(`--metadata=${key}:${value}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function identifierExists(identifier: string): Promise<boolean> {
|
export async function identifierExists(identifier: string): Promise<boolean> {
|
||||||
const result = await exec(`ia metadata ${identifier}`);
|
const result = await execFile(IA_COMMAND, ["metadata", identifier], {
|
||||||
|
env: getVenvEnv(),
|
||||||
|
});
|
||||||
const data = JSON.parse(result.stdout);
|
const data = JSON.parse(result.stdout);
|
||||||
return Object.keys(data).length > 0;
|
return Object.keys(data).length > 0;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +69,10 @@ export async function setMetadata(
|
||||||
data: { [key: string]: string }
|
data: { [key: string]: string }
|
||||||
) {
|
) {
|
||||||
const pairs = Object.entries(data).map(([key, value]) => `${key}:${value}`);
|
const pairs = Object.entries(data).map(([key, value]) => `${key}:${value}`);
|
||||||
const args = pairs.map((pair) => `--modify="${pair}"`);
|
const args = [
|
||||||
const command = `ia metadata ${identifier} ${args.join(" ")}`;
|
"metadata",
|
||||||
await exec(command);
|
identifier,
|
||||||
|
...pairs.map((pair) => `--modify=${pair}`),
|
||||||
|
];
|
||||||
|
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
172
packages/skin-database/tasks/bluesky.ts
Normal file
172
packages/skin-database/tasks/bluesky.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import * as Skins from "../data/skins";
|
||||||
|
import {
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
AtpAgent,
|
||||||
|
BlobRef,
|
||||||
|
RichText,
|
||||||
|
} from "@atproto/api";
|
||||||
|
import {
|
||||||
|
TWEET_BOT_CHANNEL_ID,
|
||||||
|
BLUESKY_USERNAME,
|
||||||
|
BLUESKY_PASSWORD,
|
||||||
|
} from "../config";
|
||||||
|
import { Client } from "discord.js";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import SkinModel from "../data/SkinModel";
|
||||||
|
import UserContext from "../data/UserContext";
|
||||||
|
import { withBufferAsTempFile } from "../utils";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const agent = new AtpAgent({ service: "https://bsky.social" });
|
||||||
|
|
||||||
|
export async function postToBluesky(
|
||||||
|
discordClient: Client,
|
||||||
|
md5: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (md5 == null) {
|
||||||
|
md5 = await Skins.getSkinToPostToBluesky();
|
||||||
|
}
|
||||||
|
if (md5 == null) {
|
||||||
|
console.error("No skins to post to Bluesky");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = await post(md5);
|
||||||
|
|
||||||
|
console.log("Going to post to discord");
|
||||||
|
const tweetBotChannel = await discordClient.channels.fetch(
|
||||||
|
TWEET_BOT_CHANNEL_ID
|
||||||
|
);
|
||||||
|
// @ts-ignore
|
||||||
|
await tweetBotChannel.send(url);
|
||||||
|
console.log("Posted to discord");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(md5: string): Promise<string> {
|
||||||
|
const ctx = new UserContext();
|
||||||
|
const skin = await SkinModel.fromMd5Assert(ctx, md5);
|
||||||
|
const screenshot = await Skins.getScreenshotBuffer(md5);
|
||||||
|
const { width, height } = await sharp(screenshot).metadata();
|
||||||
|
|
||||||
|
const image = await sharp(screenshot)
|
||||||
|
.resize(width * 2, height * 2, {
|
||||||
|
kernel: sharp.kernel.nearest,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const name = await skin.getFileName();
|
||||||
|
const url = skin.getMuseumUrl();
|
||||||
|
const screenshotFileName = await skin.getScreenshotFileName();
|
||||||
|
|
||||||
|
const status = `${name}\n`; // TODO: Should we add hashtags?
|
||||||
|
|
||||||
|
await agent.login({
|
||||||
|
identifier: BLUESKY_USERNAME!,
|
||||||
|
password: BLUESKY_PASSWORD!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await withBufferAsTempFile(
|
||||||
|
image,
|
||||||
|
screenshotFileName,
|
||||||
|
async (filePath) => {
|
||||||
|
return uploadImageFromFilePath(agent, filePath);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const postData = await buildPost(
|
||||||
|
agent,
|
||||||
|
status,
|
||||||
|
buildImageEmbed(blob, width * 2, height * 2)
|
||||||
|
);
|
||||||
|
const postResp = await agent.post(postData);
|
||||||
|
console.log(postResp);
|
||||||
|
|
||||||
|
const postId = postResp.cid;
|
||||||
|
const postUrl = postResp.uri;
|
||||||
|
|
||||||
|
await Skins.markAsPostedToBlueSky(md5, postId, postUrl);
|
||||||
|
|
||||||
|
const prefix = "Try on the ";
|
||||||
|
const suffix = "Winamp Skin Museum";
|
||||||
|
|
||||||
|
agent.post({
|
||||||
|
text: prefix + suffix,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
facets: [
|
||||||
|
{
|
||||||
|
$type: "app.bsky.richtext.facet",
|
||||||
|
index: {
|
||||||
|
byteStart: prefix.length,
|
||||||
|
byteEnd: prefix.length + suffix.length,
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
$type: "app.bsky.richtext.facet#link",
|
||||||
|
uri: url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
root: postResp,
|
||||||
|
parent: postResp,
|
||||||
|
},
|
||||||
|
$type: "app.bsky.feed.post",
|
||||||
|
});
|
||||||
|
|
||||||
|
// return permalink;
|
||||||
|
return postUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the embed data for an image. */
|
||||||
|
function buildImageEmbed(
|
||||||
|
imgBlob: BlobRef,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): AppBskyEmbedImages.Main {
|
||||||
|
const image = {
|
||||||
|
image: imgBlob,
|
||||||
|
aspectRatio: { width, height },
|
||||||
|
alt: "",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
$type: "app.bsky.embed.images",
|
||||||
|
images: [image],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the post data for an image. */
|
||||||
|
async function buildPost(
|
||||||
|
agent: AtpAgent,
|
||||||
|
rawText: string,
|
||||||
|
imageEmbed: AppBskyEmbedImages.Main
|
||||||
|
): Promise<AppBskyFeedPost.Record> {
|
||||||
|
const rt = new RichText({ text: rawText });
|
||||||
|
await rt.detectFacets(agent);
|
||||||
|
const { text, facets } = rt;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
facets,
|
||||||
|
$type: "app.bsky.feed.post",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
embed: {
|
||||||
|
$type: "app.bsky.embed.recordWithMedia",
|
||||||
|
...imageEmbed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an image from a URL to Bluesky. */
|
||||||
|
async function uploadImageFromFilePath(
|
||||||
|
agent: AtpAgent,
|
||||||
|
filePath: string
|
||||||
|
): Promise<BlobRef> {
|
||||||
|
const imageBuff = fs.readFileSync(filePath);
|
||||||
|
const imgU8 = new Uint8Array(imageBuff);
|
||||||
|
|
||||||
|
const dstResp = await agent.uploadBlob(imgU8);
|
||||||
|
if (!dstResp.success) {
|
||||||
|
throw new Error("Failed to upload image");
|
||||||
|
}
|
||||||
|
return dstResp.data.blob;
|
||||||
|
}
|
||||||
181
packages/skin-database/tasks/computeScrollRanking.ts
Normal file
181
packages/skin-database/tasks/computeScrollRanking.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { knex } from "../db";
|
||||||
|
import type { UserEvent } from "../app/(modern)/scroll/Events";
|
||||||
|
|
||||||
|
const WEIGHTS = {
|
||||||
|
viewDurationPerSecond: 0.1,
|
||||||
|
like: 10,
|
||||||
|
download: 5,
|
||||||
|
share: 15,
|
||||||
|
readmeExpand: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SessionAggregate {
|
||||||
|
skinViewDurations: Map<string, number>;
|
||||||
|
skinsLiked: Set<string>;
|
||||||
|
skinsDownloaded: Set<string>;
|
||||||
|
readmesExpanded: Set<string>;
|
||||||
|
sharesSucceeded: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkinRanking {
|
||||||
|
skinMd5: string;
|
||||||
|
totalViewDurationMs: number;
|
||||||
|
viewCount: number;
|
||||||
|
averageViewDurationMs: number;
|
||||||
|
likeCount: number;
|
||||||
|
downloadCount: number;
|
||||||
|
shareCount: number;
|
||||||
|
readmeExpandCount: number;
|
||||||
|
rankingScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const rankings = await computeSkinRankings();
|
||||||
|
console.log(JSON.stringify(rankings, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during aggregation:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await knex.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeSkinRankings(): Promise<SkinRanking[]> {
|
||||||
|
const sessionMap = await buildSessionAggregates();
|
||||||
|
|
||||||
|
const skinDataMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
viewDurations: number[];
|
||||||
|
likes: number;
|
||||||
|
downloads: number;
|
||||||
|
shares: number;
|
||||||
|
readmeExpands: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
function getSkinData(skinMd5: string) {
|
||||||
|
if (!skinDataMap.has(skinMd5)) {
|
||||||
|
skinDataMap.set(skinMd5, {
|
||||||
|
viewDurations: [],
|
||||||
|
likes: 0,
|
||||||
|
downloads: 0,
|
||||||
|
shares: 0,
|
||||||
|
readmeExpands: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return skinDataMap.get(skinMd5)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessionMap.values()) {
|
||||||
|
for (const [skinMd5, duration] of session.skinViewDurations) {
|
||||||
|
getSkinData(skinMd5).viewDurations.push(duration);
|
||||||
|
}
|
||||||
|
for (const skinMd5 of session.skinsLiked) {
|
||||||
|
getSkinData(skinMd5).likes++;
|
||||||
|
}
|
||||||
|
for (const skinMd5 of session.skinsDownloaded) {
|
||||||
|
getSkinData(skinMd5).downloads++;
|
||||||
|
}
|
||||||
|
for (const skinMd5 of session.sharesSucceeded) {
|
||||||
|
getSkinData(skinMd5).shares++;
|
||||||
|
}
|
||||||
|
for (const skinMd5 of session.readmesExpanded) {
|
||||||
|
getSkinData(skinMd5).readmeExpands++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankings: SkinRanking[] = [];
|
||||||
|
|
||||||
|
for (const [skinMd5, data] of skinDataMap) {
|
||||||
|
const totalViewDurationMs = data.viewDurations.reduce(
|
||||||
|
(sum, duration) => sum + duration,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const viewCount = data.viewDurations.length;
|
||||||
|
const averageViewDurationMs =
|
||||||
|
viewCount > 0 ? totalViewDurationMs / viewCount : 0;
|
||||||
|
|
||||||
|
const rankingScore =
|
||||||
|
(averageViewDurationMs / 1000) * WEIGHTS.viewDurationPerSecond +
|
||||||
|
data.likes * WEIGHTS.like +
|
||||||
|
data.downloads * WEIGHTS.download +
|
||||||
|
data.shares * WEIGHTS.share +
|
||||||
|
data.readmeExpands * WEIGHTS.readmeExpand;
|
||||||
|
|
||||||
|
rankings.push({
|
||||||
|
skinMd5,
|
||||||
|
totalViewDurationMs,
|
||||||
|
viewCount,
|
||||||
|
averageViewDurationMs,
|
||||||
|
likeCount: data.likes,
|
||||||
|
downloadCount: data.downloads,
|
||||||
|
shareCount: data.shares,
|
||||||
|
readmeExpandCount: data.readmeExpands,
|
||||||
|
rankingScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rankings.sort((a, b) => b.rankingScore - a.rankingScore);
|
||||||
|
return rankings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSessionAggregates(): Promise<
|
||||||
|
Map<string, SessionAggregate>
|
||||||
|
> {
|
||||||
|
const events = await knex("user_log_events")
|
||||||
|
.select("session_id", "timestamp", "metadata")
|
||||||
|
.orderBy("timestamp", "asc");
|
||||||
|
|
||||||
|
const sessionMap = new Map<string, SessionAggregate>();
|
||||||
|
|
||||||
|
function getSession(sessionId: string): SessionAggregate {
|
||||||
|
if (!sessionMap.has(sessionId)) {
|
||||||
|
sessionMap.set(sessionId, {
|
||||||
|
skinViewDurations: new Map(),
|
||||||
|
skinsLiked: new Set(),
|
||||||
|
skinsDownloaded: new Set(),
|
||||||
|
readmesExpanded: new Set(),
|
||||||
|
sharesSucceeded: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sessionMap.get(sessionId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of events) {
|
||||||
|
const event: UserEvent = JSON.parse(row.metadata);
|
||||||
|
const session = getSession(row.session_id);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "skin_view_end":
|
||||||
|
session.skinViewDurations.set(event.skinMd5, event.durationMs);
|
||||||
|
break;
|
||||||
|
case "readme_expand":
|
||||||
|
session.readmesExpanded.add(event.skinMd5);
|
||||||
|
break;
|
||||||
|
case "skin_download":
|
||||||
|
session.skinsDownloaded.add(event.skinMd5);
|
||||||
|
break;
|
||||||
|
case "skin_like":
|
||||||
|
if (event.liked) {
|
||||||
|
session.skinsLiked.add(event.skinMd5);
|
||||||
|
} else {
|
||||||
|
session.skinsLiked.delete(event.skinMd5);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "share_success":
|
||||||
|
session.sharesSucceeded.add(event.skinMd5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildSessionAggregates };
|
||||||
|
export type { SessionAggregate };
|
||||||
|
|
@ -7,6 +7,7 @@ import * as Skins from "../data/skins";
|
||||||
import * as CloudFlare from "../CloudFlare";
|
import * as CloudFlare from "../CloudFlare";
|
||||||
import SkinModel from "../data/SkinModel";
|
import SkinModel from "../data/SkinModel";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-require-imports
|
||||||
const Shooter = require("../shooter");
|
const Shooter = require("../shooter");
|
||||||
|
|
||||||
export async function screenshot(skin: SkinModel, shooter: typeof Shooter) {
|
export async function screenshot(skin: SkinModel, shooter: typeof Shooter) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import SkinModel from "../data/SkinModel";
|
||||||
import * as Parallel from "async-parallel";
|
import * as Parallel from "async-parallel";
|
||||||
import IaItemModel from "../data/IaItemModel";
|
import IaItemModel from "../data/IaItemModel";
|
||||||
import DiscordEventHandler from "../api/DiscordEventHandler";
|
import DiscordEventHandler from "../api/DiscordEventHandler";
|
||||||
import { exec } from "../utils";
|
import { execFile } from "../utils";
|
||||||
import * as IAService from "../services/internetArchive";
|
import * as IAService from "../services/internetArchive";
|
||||||
|
|
||||||
export async function findItemsMissingImages(): Promise<string[]> {
|
export async function findItemsMissingImages(): Promise<string[]> {
|
||||||
|
|
@ -192,7 +192,7 @@ async function getNewIdentifier(filename: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archive(skin: SkinModel): Promise<string> {
|
export async function archive(skin: SkinModel): Promise<string> {
|
||||||
const filename = await skin.getFileName();
|
const filename = await skin.getFileName(true);
|
||||||
|
|
||||||
const screenshotFilename = await skin.getScreenshotFileName();
|
const screenshotFilename = await skin.getScreenshotFileName();
|
||||||
const title = `Winamp Skin: ${filename}`;
|
const title = `Winamp Skin: ${filename}`;
|
||||||
|
|
@ -207,8 +207,36 @@ export async function archive(skin: SkinModel): Promise<string> {
|
||||||
|
|
||||||
console.log(`Going to try to upload with identifier "${identifier}"...`);
|
console.log(`Going to try to upload with identifier "${identifier}"...`);
|
||||||
|
|
||||||
const command = `ia upload ${identifier} "${skinFile}" "${screenshotFile}" --metadata="collection:winampskins" --metadata="skintype:wsz" --metadata="mediatype:software" --metadata="title:${title}"`;
|
// Path to the ia command in the virtual environment
|
||||||
await exec(command, { encoding: "utf8" });
|
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
|
||||||
|
|
||||||
|
// Environment variables for the virtual environment
|
||||||
|
const venvEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
|
||||||
|
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
collection: "winampskins",
|
||||||
|
skintype: "wsz",
|
||||||
|
mediatype: "software",
|
||||||
|
title: title,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build arguments array for ia upload command
|
||||||
|
const args = [
|
||||||
|
"upload",
|
||||||
|
identifier,
|
||||||
|
skinFile,
|
||||||
|
screenshotFile,
|
||||||
|
`--metadata=collection:${metadata.collection}`,
|
||||||
|
`--metadata=skintype:${metadata.skintype}`,
|
||||||
|
`--metadata=mediatype:${metadata.mediatype}`,
|
||||||
|
`--metadata=title:${metadata.title}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
await execFile(IA_COMMAND, args, { env: venvEnv });
|
||||||
await knex("ia_items").insert({ skin_md5: skin.getMd5(), identifier });
|
await knex("ia_items").insert({ skin_md5: skin.getMd5(), identifier });
|
||||||
return identifier;
|
return identifier;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,7 @@
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|
@ -37,13 +33,9 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./**/*",
|
"./**/*",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts",
|
||||||
|
".next/types/app/(legacy)/about/page.tsx"
|
||||||
],
|
],
|
||||||
"lib": [
|
"lib": ["es2015"],
|
||||||
"es2015"
|
"exclude": ["node_modules", "dist"]
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type SkinRow = {
|
||||||
skin_type: number;
|
skin_type: number;
|
||||||
emails: string;
|
emails: string;
|
||||||
// readme_text: string;
|
// readme_text: string;
|
||||||
average_color: string;
|
average_color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TweetRow = {
|
export type TweetRow = {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import child_process from "child_process";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export const exec = util.promisify(child_process.exec);
|
export const exec = util.promisify(child_process.exec);
|
||||||
|
export const execFile = util.promisify(child_process.execFile);
|
||||||
|
|
||||||
const temp = _temp.track();
|
const temp = _temp.track();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
pnpm dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vercel/og": "^0.0.20",
|
"@vercel/og": "^0.0.20",
|
||||||
"next": "13.0.3",
|
"next": "13.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vercel": "^28.4.17"
|
"vercel": "^28.4.17"
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,10 @@ async function searchImage(query) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderGrid({ skins, title }) {
|
function HeaderGrid({ skins, _title }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "black",
|
|
||||||
color: "white",
|
color: "white",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
|
@ -110,7 +109,6 @@ function HeaderGrid({ skins, title }) {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
|
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ export default function Frame({ children }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "black",
|
|
||||||
color: "white",
|
color: "white",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
|
@ -10,7 +9,6 @@ export default function Frame({ children }) {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
|
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ npm install --save webamp
|
||||||
From here you can import Webamp in your JavaScript code:
|
From here you can import Webamp in your JavaScript code:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import Webamp from "webamp";
|
import Webamp from "webamp/butterchurn";
|
||||||
// ... use Webamp here
|
// ... use Webamp here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -25,19 +25,7 @@ ES modules can be imported via URL directly inside a `<script type="module">` ta
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import Webamp from "https://unpkg.com/webamp@^2";
|
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||||
// ... use Webamp here
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Include via a script tag
|
|
||||||
|
|
||||||
This will make the Webamp constructor available as a `window` property: `window.Webamp` keep in mind that you will need to use the `type="module"` attribute on the script tag,
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://unpkg.com/webamp@^2" type="module"></script>
|
|
||||||
<script>
|
|
||||||
const Webamp = window.Webamp;
|
|
||||||
// ... use Webamp here
|
// ... use Webamp here
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Create a DOM element somewhere in your HTML document. This will be used by Webam
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
**Webamp will not actually insert itself as a child of this element.** It will will insert itself as a child of the body element, and will attempt to center itself within this element. This is needed to allow the various Webamp windows to dragged around the page unencumbered.
|
**Webamp will not actually insert itself as a child of this element.** It will will insert itself as a child of the body element, and will attempt to center itself within this element. This is needed to allow the various Webamp windows to dragged around the page unencumbered.
|
||||||
|
|
||||||
|
If you want Webamp to be a child of a specific element, use the [`renderInto(domNode)`](./06_API/03_instance-methods.md#renderintodomnode-htmlelement-promisevoid) method instead _(available in unreleased version)_. Note that the target element must have a non-static CSS position (e.g., `position: relative`) for this to work correctly.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Initialize Webamp instance
|
## Initialize Webamp instance
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Webamp uses [Butterchurn](https://butterchurnviz.com/) to provide a Milkdrop visualizer. Butterchurn is a JavaScript port of the original Milkdrop visualizer, and it can run in any modern web browser.
|
Webamp uses [Butterchurn](https://butterchurnviz.com/) to provide a Milkdrop visualizer. Butterchurn is a JavaScript port of the original Milkdrop visualizer, and it can run in any modern web browser.
|
||||||
|
|
||||||
The [next release](../12_changelog.md#unreleased) of Webamp will includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled.
|
Starting with [v2.2.0](../12_changelog.md#220) Webamp includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled. See "Minimal Milkdrop" in [examples](../04_examples.md) for more details.
|
||||||
|
|
||||||
## Hotkeys
|
## Hotkeys
|
||||||
|
|
||||||
|
|
|
||||||
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