mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-24 02:36:00 +00:00
Compare commits
No commits in common. "master" and "2.2.0" have entirely different histories.
148 changed files with 797 additions and 8201 deletions
100
.eslintrc
100
.eslintrc
|
|
@ -4,11 +4,15 @@
|
||||||
"jsx": true,
|
"jsx": true,
|
||||||
"sourceType": "module",
|
"sourceType": "module",
|
||||||
"ecmaFeatures": {
|
"ecmaFeatures": {
|
||||||
"jsx": true
|
"jsx": true,
|
||||||
|
"experimentalObjectRestSpread": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": ["prettier", "@typescript-eslint"],
|
"plugins": ["prettier"],
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "15.2"
|
||||||
|
},
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
"node": {
|
"node": {
|
||||||
"extensions": [".js", ".ts", ".tsx"]
|
"extensions": [".js", ".ts", ".tsx"]
|
||||||
|
|
@ -17,85 +21,27 @@
|
||||||
},
|
},
|
||||||
"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,9 +50,6 @@ 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
|
||||||
|
|
@ -64,8 +61,6 @@ 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
|
||||||
|
|
@ -86,40 +81,31 @@ 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: Set version for tagged release
|
- name: Build release version
|
||||||
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||||
run: |
|
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
|
||||||
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: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
npm publish ${TAG} --ignore-scripts
|
||||||
npm publish --tag=next --ignore-scripts --provenance
|
env:
|
||||||
else
|
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||||
npm publish --ignore-scripts --provenance
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
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: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
npm publish ${TAG} --ignore-scripts
|
||||||
npm publish --tag=next --ignore-scripts --provenance
|
env:
|
||||||
else
|
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||||
npm publish --ignore-scripts --provenance
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
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: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
npm publish ${TAG} --ignore-scripts
|
||||||
npm publish --tag=next --ignore-scripts --provenance
|
env:
|
||||||
else
|
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||||
npm publish --ignore-scripts --provenance
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
fi
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
|
||||||
6
deploy.sh
Executable file
6
deploy.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/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
24
examples/lazy/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
# 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?
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# `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.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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
1
examples/lazy/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"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/butterchurn";
|
import Webamp from "https://unpkg.com/webamp@^2";
|
||||||
const webamp = new Webamp({
|
const webamp = new Webamp({
|
||||||
initialTracks: [
|
initialTracks: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
/**
|
/**
|
||||||
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
|
* Webamp includes a `webamp/butterchurn` entrypoint which includes the Butterchurn
|
||||||
* entrypoint which includes the Butterchurn library to enable the
|
* library to enable the Milkdrop visualizer. It hasn't been included in a
|
||||||
* Milkdrop visualizer.
|
* stable version yet, until it is, you can use by installing
|
||||||
|
* `webamp@next`.
|
||||||
*/
|
*/
|
||||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
import Webamp from "https://unpkg.com/webamp@0.0.0-next-7cdc35b/butterchurn";
|
||||||
const webamp = new Webamp({
|
const webamp = new Webamp({
|
||||||
initialTracks: [
|
initialTracks: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<!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 run deploy"
|
command = "pnpm 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": "npx turbo lint",
|
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
|
||||||
"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": "npx turbo webamp#build webamp-modern#build --concurrency 1 && mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern",
|
"deploy": "sh deploy.sh",
|
||||||
"format": "prettier --write '**/*.{js,ts,tsx}'"
|
"format": "prettier --write '**/*.{js,ts,tsx}'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -43,10 +43,5 @@
|
||||||
"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,7 +32,6 @@
|
||||||
"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,28 +1,32 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["plugin:@typescript-eslint/recommended"],
|
env: {
|
||||||
|
node: true,
|
||||||
|
es2021: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 12,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["@typescript-eslint"],
|
||||||
rules: {
|
rules: {
|
||||||
// Disable rules that conflict with the project's style
|
// "no-console": "warn",
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
"@typescript-eslint/no-require-imports": "off", // Allow require() in JS files
|
||||||
"@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",
|
||||||
// Override the base no-shadow rule since it conflicts with TypeScript
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"no-shadow": "off",
|
"warn",
|
||||||
// Relax rules for this project's existing style
|
{
|
||||||
camelcase: "off",
|
argsIgnorePattern: "^_",
|
||||||
"dot-notation": "off",
|
varsIgnorePattern: "^_",
|
||||||
eqeqeq: "off",
|
caughtErrorsIgnorePattern: "^_",
|
||||||
"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,3 +1,8 @@
|
||||||
|
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 */
|
||||||
|
|
@ -7,3 +12,18 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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,6 +1,7 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -23,7 +24,11 @@ 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> {
|
||||||
return await this._model.getFileName(normalize_extension);
|
const filename = await this._model.getFileName();
|
||||||
|
if (normalize_extension) {
|
||||||
|
return path.parse(filename).name + ".wsz";
|
||||||
|
}
|
||||||
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
museum_url(): string {
|
museum_url(): string {
|
||||||
|
|
@ -41,7 +46,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 | null {
|
average_color(): string {
|
||||||
return this._model.getAverageColor();
|
return this._model.getAverageColor();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -69,34 +69,19 @@ 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 async function filename(
|
export 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> {
|
||||||
const baseFilename = await skin.filename(normalize_extension);
|
return skin.filename(normalize_extension);
|
||||||
|
|
||||||
if (!include_museum_id) {
|
|
||||||
return baseFilename;
|
|
||||||
}
|
|
||||||
|
|
||||||
const museumId = skin._model.getId();
|
|
||||||
const segments = baseFilename.split(".");
|
|
||||||
const fileExtension = segments.pop();
|
|
||||||
|
|
||||||
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# 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.
|
||||||
|
|
@ -93,10 +92,6 @@ 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.
|
||||||
|
|
@ -169,16 +164,6 @@ 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"""
|
||||||
|
|
@ -192,10 +177,6 @@ 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.
|
||||||
|
|
@ -326,10 +307,6 @@ 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.
|
||||||
|
|
@ -407,8 +384,6 @@ 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,10 +2,8 @@
|
||||||
* 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 { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
|
import { getUserContext as getUserContext } from "./index";
|
||||||
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";
|
||||||
|
|
@ -28,75 +26,6 @@ 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() {
|
||||||
|
|
@ -258,13 +187,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -328,33 +253,70 @@ export function getSchema(): GraphQLSchema {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
|
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
||||||
name: "BulkDownloadConnection",
|
name: "ArchiveFile",
|
||||||
description: "Connection for bulk download skin metadata",
|
description: "A file found within a Winamp Skin's .wsz archive",
|
||||||
fields() {
|
fields() {
|
||||||
return {
|
return {
|
||||||
estimatedSizeBytes: {
|
date: {
|
||||||
description: "Estimated total size in bytes (approximation for progress indication)",
|
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
||||||
name: "estimatedSizeBytes",
|
name: "date",
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
resolve(source, args, context, info) {
|
resolve(source) {
|
||||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
return assertNonNull(source.getIsoDate());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nodes: {
|
file_md5: {
|
||||||
description: "List of skin metadata for bulk download",
|
description: "The md5 hash of the file within the archive",
|
||||||
name: "nodes",
|
name: "file_md5",
|
||||||
type: new GraphQLList(new GraphQLNonNull(SkinType)),
|
type: GraphQLString,
|
||||||
resolve(source, _args, context) {
|
resolve(source) {
|
||||||
return assertNonNull(source.nodes(getUserContext(context)));
|
return assertNonNull(source.getFileMd5());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
totalCount: {
|
filename: {
|
||||||
description: "Total number of skins available for download",
|
description: "Filename of the file within the archive",
|
||||||
name: "totalCount",
|
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,
|
type: GraphQLInt,
|
||||||
resolve(source, args, context, info) {
|
resolve(source) {
|
||||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -420,13 +382,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -587,13 +545,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -949,30 +903,13 @@ 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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -986,6 +923,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: InternetArchiveItemType,
|
type: InternetArchiveItemType,
|
||||||
args: {
|
args: {
|
||||||
identifier: {
|
identifier: {
|
||||||
|
name: "identifier",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -999,6 +937,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: SkinType,
|
type: SkinType,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
|
name: "md5",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1012,6 +951,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: TweetType,
|
type: TweetType,
|
||||||
args: {
|
args: {
|
||||||
url: {
|
url: {
|
||||||
|
name: "url",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1033,10 +973,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -1051,6 +993,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: NodeType,
|
type: NodeType,
|
||||||
args: {
|
args: {
|
||||||
id: {
|
id: {
|
||||||
|
name: "id",
|
||||||
type: new GraphQLNonNull(GraphQLID)
|
type: new GraphQLNonNull(GraphQLID)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1064,14 +1007,17 @@ 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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1085,14 +1031,17 @@ 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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1114,17 +1063,21 @@ 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1146,14 +1099,17 @@ 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1167,6 +1123,7 @@ 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)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1181,6 +1138,7 @@ 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)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1247,6 +1205,7 @@ 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)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1260,9 +1219,11 @@ 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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1283,6 +1244,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
|
name: "md5",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1296,6 +1258,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
|
name: "md5",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1309,6 +1272,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
|
name: "md5",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1322,6 +1286,7 @@ export function getSchema(): GraphQLSchema {
|
||||||
type: GraphQLBoolean,
|
type: GraphQLBoolean,
|
||||||
args: {
|
args: {
|
||||||
md5: {
|
md5: {
|
||||||
|
name: "md5",
|
||||||
type: new GraphQLNonNull(GraphQLString)
|
type: new GraphQLNonNull(GraphQLString)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1335,12 +1300,15 @@ 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1360,19 +1328,8 @@ 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, BulkDownloadConnectionType, 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, 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)
|
||||||
}),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function Page() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function Page() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import App from "../App";
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return <App />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function Page() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function Page() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function Page() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"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";
|
|
||||||
};
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
"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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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 */
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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<any>(null);
|
const gridRef = React.useRef();
|
||||||
const itemRef = React.useRef<number>(0);
|
const itemRef = React.useRef();
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (gridRef.current == null) {
|
if (gridRef.current == null) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
@ -1,483 +0,0 @@
|
||||||
"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,3 +1,4 @@
|
||||||
|
import App from "../../../App";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { generateSkinPageMetadata } from "../skinMetadata";
|
import { generateSkinPageMetadata } from "../skinMetadata";
|
||||||
|
|
||||||
|
|
@ -7,5 +8,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return null;
|
return <App />;
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import App from "../../App";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { generateSkinPageMetadata } from "./skinMetadata";
|
import { generateSkinPageMetadata } from "./skinMetadata";
|
||||||
|
|
||||||
|
|
@ -7,5 +8,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return null;
|
return <App />;
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -35,14 +35,11 @@ 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 {
|
||||||
|
|
@ -84,30 +81,21 @@ 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, bluesky }) => {
|
.action(async (md5, { twitter, instagram, mastodon }) => {
|
||||||
|
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"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -173,7 +161,7 @@ program
|
||||||
console.log("====================================");
|
console.log("====================================");
|
||||||
}
|
}
|
||||||
if (purge) {
|
if (purge) {
|
||||||
// cat purge | xargs -I {} pnpm cli skin --purge {}
|
// cat purge | xargs -I {} yarn 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);
|
||||||
|
|
@ -310,14 +298,6 @@ 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.")
|
||||||
|
|
@ -458,7 +438,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.updateSearchIndexes(ctx, md5s));
|
console.log(await Skins.updateSearchIndexs(ctx, md5s));
|
||||||
}
|
}
|
||||||
if (refreshContentHash) {
|
if (refreshContentHash) {
|
||||||
const ctx = new UserContext();
|
const ctx = new UserContext();
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ 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];
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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(normalizeExtension?: boolean): Promise<string> {
|
async getFileName(): 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,9 +167,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +193,9 @@ 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 | null {
|
getAverageColor(): string {
|
||||||
return this.row.average_color ?? null;
|
return this.row.average_color;
|
||||||
}
|
}
|
||||||
|
|
||||||
getBuffer = mem(async (): Promise<Buffer> => {
|
getBuffer = mem(async (): Promise<Buffer> => {
|
||||||
|
|
|
||||||
|
|
@ -144,17 +144,6 @@ 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 };
|
||||||
|
|
@ -254,7 +243,7 @@ async function getSearchIndexes(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSearchIndexes(
|
export async function updateSearchIndexs(
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
md5s: string[]
|
md5s: string[]
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
|
@ -276,7 +265,7 @@ export async function updateSearchIndex(
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
md5: string
|
md5: string
|
||||||
): Promise<any | null> {
|
): Promise<any | null> {
|
||||||
return updateSearchIndexes(ctx, [md5]);
|
return updateSearchIndexs(ctx, [md5]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hideSkin(md5: string): Promise<void> {
|
export async function hideSkin(md5: string): Promise<void> {
|
||||||
|
|
@ -411,7 +400,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -562,31 +550,6 @@ 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 })
|
||||||
|
|
@ -719,53 +682,6 @@ 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,
|
||||||
|
|
|
||||||
28
packages/skin-database/ecosystem.config.js
Normal file
28
packages/skin-database/ecosystem.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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,5 +31,3 @@ 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,21 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { connect, useSelector } from "react-redux";
|
import { connect } 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 {
|
import { ABOUT_PAGE, REVIEW_PAGE } from "./constants";
|
||||||
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";
|
||||||
|
|
@ -81,7 +78,6 @@ 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,7 +1,9 @@
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState } 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,5 +1,4 @@
|
||||||
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");
|
||||||
|
|
@ -7,15 +6,14 @@ 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 = gql`
|
const queryText = Utils.gql`
|
||||||
query SearchQuery($query: String!) {
|
query SearchQuery($query: String!) {
|
||||||
search_classic_skins(query: $query, first: 500) {
|
search_classic_skins(query: $query, first: 500) {
|
||||||
filename(normalize_extension: true)
|
filename
|
||||||
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(() => {
|
||||||
let blob = new Blob([text], {
|
var 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,7 +1,6 @@
|
||||||
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";
|
||||||
|
|
@ -33,8 +32,9 @@ 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";
|
||||||
|
|
||||||
// Sites have been unified, we can point to ourselves now
|
// Note: This is a Cloudflare proxy for api.webamp.org which
|
||||||
export const API_URL = "https://skins.webamp.org";
|
// provides some additional caching.
|
||||||
|
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) {
|
||||||
let blobSlice =
|
var 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() {
|
||||||
let start = currentChunk * chunkSize,
|
var 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(normalize_extension: true)
|
filename
|
||||||
nsfw
|
nsfw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -462,10 +462,7 @@ const urlEpic = (actions, state) => {
|
||||||
|
|
||||||
const newUrl = proposedUrl.toString();
|
const newUrl = proposedUrl.toString();
|
||||||
|
|
||||||
// Avoid clobbering URL for beta site
|
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
||||||
if (!window.location.href.includes("/scroll")) {
|
|
||||||
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
ignoreElements()
|
ignoreElements()
|
||||||
);
|
);
|
||||||
|
|
@ -484,7 +481,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(normalize_extension: true)
|
filename
|
||||||
nsfw
|
nsfw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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,9 +22,7 @@ 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) => {
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
setTimeout(resolve, 5000);
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function museumUrlFromHash(hash) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowSize() {
|
export function getWindowSize() {
|
||||||
let w = window,
|
var w = window,
|
||||||
d = document,
|
d = document,
|
||||||
e = d.documentElement,
|
e = d.documentElement,
|
||||||
g = d.getElementsByTagName("body")[0],
|
g = d.getElementsByTagName("body")[0],
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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"`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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,16 +1,6 @@
|
||||||
/** @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,7 +4,6 @@
|
||||||
"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",
|
||||||
|
|
@ -25,10 +24,9 @@
|
||||||
"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.6",
|
"next": "^15.3.3",
|
||||||
"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",
|
||||||
|
|
@ -48,8 +46,7 @@
|
||||||
"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 .",
|
||||||
|
|
@ -76,10 +73,9 @@
|
||||||
"@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.0-main-e655d1ae",
|
"grats": "^0.0.31",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
||||||
pnpm install --frozen-lockfile
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Build the site
|
# Build the site
|
||||||
pnpm run build
|
yarn run build
|
||||||
|
|
||||||
# Reload processes via PM2
|
# Reload processes via PM2
|
||||||
pm2 reload ecosystem.config.js
|
pm2 reload ecosystem.config.js
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { execFile } from "../utils";
|
import { exec } 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}`);
|
||||||
|
|
@ -22,8 +11,9 @@ 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 result = await execFile(IA_COMMAND, ["tasks", identifier], {
|
const command = `ia tasks ${identifier}`;
|
||||||
env: getVenvEnv(),
|
const result = await exec(command, {
|
||||||
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
return result.stdout
|
return result.stdout
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -35,31 +25,12 @@ export async function uploadFile(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
filepath: string
|
filepath: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await execFile(IA_COMMAND, ["upload", identifier, filepath], {
|
const command = `ia upload ${identifier} "${filepath}"`;
|
||||||
env: getVenvEnv(),
|
await exec(command, { encoding: "utf8" });
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 execFile(IA_COMMAND, ["metadata", identifier], {
|
const result = await exec(`ia 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;
|
||||||
}
|
}
|
||||||
|
|
@ -69,10 +40,7 @@ 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 = [
|
const args = pairs.map((pair) => `--modify="${pair}"`);
|
||||||
"metadata",
|
const command = `ia metadata ${identifier} ${args.join(" ")}`;
|
||||||
identifier,
|
await exec(command);
|
||||||
...pairs.map((pair) => `--modify=${pair}`),
|
|
||||||
];
|
|
||||||
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
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,7 +7,6 @@ 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 { execFile } from "../utils";
|
import { exec } 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(true);
|
const filename = await skin.getFileName();
|
||||||
|
|
||||||
const screenshotFilename = await skin.getScreenshotFileName();
|
const screenshotFilename = await skin.getScreenshotFileName();
|
||||||
const title = `Winamp Skin: ${filename}`;
|
const title = `Winamp Skin: ${filename}`;
|
||||||
|
|
@ -207,36 +207,8 @@ 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}"...`);
|
||||||
|
|
||||||
// Path to the ia command in the virtual environment
|
const command = `ia upload ${identifier} "${skinFile}" "${screenshotFile}" --metadata="collection:winampskins" --metadata="skintype:wsz" --metadata="mediatype:software" --metadata="title:${title}"`;
|
||||||
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
|
await exec(command, { encoding: "utf8" });
|
||||||
|
|
||||||
// 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,7 +23,11 @@
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|
@ -33,9 +37,13 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./**/*",
|
"./**/*",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts"
|
||||||
".next/types/app/(legacy)/about/page.tsx"
|
|
||||||
],
|
],
|
||||||
"lib": ["es2015"],
|
"lib": [
|
||||||
"exclude": ["node_modules", "dist"]
|
"es2015"
|
||||||
|
],
|
||||||
|
"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,7 +6,6 @@ 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,7 +5,9 @@ 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
|
||||||
pnpm dev
|
npm run 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": "18.2.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vercel": "^28.4.17"
|
"vercel": "^28.4.17"
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,11 @@ 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%",
|
||||||
|
|
@ -109,6 +110,7 @@ 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,6 +2,7 @@ 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%",
|
||||||
|
|
@ -9,6 +10,7 @@ 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/butterchurn";
|
import Webamp from "webamp";
|
||||||
// ... use Webamp here
|
// ... use Webamp here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -25,7 +25,19 @@ 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/butterchurn";
|
import Webamp from "https://unpkg.com/webamp@^2";
|
||||||
|
// ... 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,8 +12,6 @@ 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## 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