mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 02:15:01 +00:00
Compare commits
78 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe3235d51 | ||
|
|
54dbe369d4 | ||
|
|
bce845962f | ||
|
|
4b784b6eaf | ||
|
|
52f12327fa | ||
|
|
1d10f7a1f4 | ||
|
|
99f06d3bfc | ||
|
|
162025f8a0 | ||
|
|
f600fb0344 | ||
|
|
04c396ed1f | ||
|
|
f74d7a6cdf | ||
|
|
1fb930cd63 | ||
|
|
8d4ff41f42 | ||
|
|
e062a51a88 | ||
|
|
0895f9191f | ||
|
|
3b4e5b17c3 | ||
|
|
91618c9c6b | ||
|
|
6c732f8e24 | ||
|
|
d87cb6ffa3 | ||
|
|
6997c852f9 | ||
|
|
61476591f8 | ||
|
|
33003a8f8f | ||
|
|
50a7c2df49 | ||
|
|
1f875a6155 | ||
|
|
26a6002ce8 | ||
|
|
bd6c978d79 | ||
|
|
18ee5418b6 | ||
|
|
af21934296 | ||
|
|
4b793c30b2 | ||
|
|
a6b0350a00 | ||
|
|
964a7c5f2f | ||
|
|
b00e359a78 | ||
|
|
d159308352 | ||
|
|
d687f4b06c | ||
|
|
8fa7701b47 | ||
|
|
1da77a640a | ||
|
|
642fb964d6 | ||
|
|
8358d4843c | ||
|
|
bbd1d1224e | ||
|
|
b672de2515 | ||
|
|
014c8eab28 | ||
|
|
a56cbc54c5 | ||
|
|
8efe121f3c | ||
|
|
c778464c42 | ||
|
|
d6245c7c7e | ||
|
|
f1339901e6 | ||
|
|
811fc977c4 | ||
|
|
7afe3bd45b | ||
|
|
608242b200 | ||
|
|
f3054192e6 | ||
|
|
0b2ff44b1c | ||
|
|
52ff84d29b | ||
|
|
fbe3a0090f | ||
|
|
0705e9d89e | ||
|
|
340e2249ae | ||
|
|
4b405bc831 | ||
|
|
50d5dbbf4f | ||
|
|
96ffdcda59 | ||
|
|
e5ed88c8ec | ||
|
|
3c882550e3 | ||
|
|
6434ecc626 | ||
|
|
a20bab1877 | ||
|
|
a0cecb8f93 | ||
|
|
0608b2f9c6 | ||
|
|
27ba138a3e | ||
|
|
cb651adfaf | ||
|
|
224b4b8058 | ||
|
|
f35f1242ca | ||
|
|
acff24b7bb | ||
|
|
e99b2ab6f7 | ||
|
|
a30ab82ccc | ||
|
|
eaba9667e2 | ||
|
|
41cfbbb63c | ||
|
|
6f8f85c865 | ||
|
|
e82db4cddd | ||
|
|
0d7cb8285f | ||
|
|
a4dec85406 | ||
|
|
5e88d4e37b |
148 changed files with 8200 additions and 796 deletions
100
.eslintrc
100
.eslintrc
|
|
@ -4,15 +4,11 @@
|
|||
"jsx": true,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
"experimentalObjectRestSpread": true
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": ["prettier"],
|
||||
"plugins": ["prettier", "@typescript-eslint"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "15.2"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".ts", ".tsx"]
|
||||
|
|
@ -21,27 +17,85 @@
|
|||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"amd": true,
|
||||
"es6": true,
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"window": true,
|
||||
"document": true,
|
||||
"console": true,
|
||||
"navigator": true,
|
||||
"alert": true,
|
||||
"Blob": true,
|
||||
"fetch": true,
|
||||
"FileReader": true,
|
||||
"Element": true,
|
||||
"AudioNode": true,
|
||||
"MutationObserver": true,
|
||||
"Image": true,
|
||||
"location": true
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"no-constant-binary-expression": "error"
|
||||
"no-constant-binary-expression": "error",
|
||||
"array-callback-return": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"block-scoped-var": "warn",
|
||||
"camelcase": "error",
|
||||
"constructor-super": "error",
|
||||
"dot-notation": "error",
|
||||
"eqeqeq": ["error", "smart"],
|
||||
"guard-for-in": "error",
|
||||
"max-depth": ["warn", 4],
|
||||
"new-cap": "error",
|
||||
"no-caller": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-div-regex": "warn",
|
||||
"no-dupe-args": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-eval": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extend-native": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-extra-semi": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inner-declarations": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-nested-ternary": "warn",
|
||||
"no-new-object": "error",
|
||||
"no-new-symbol": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-octal": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-proto": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-shadow": "warn",
|
||||
"no-this-before-super": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-var": "error",
|
||||
"no-with": "error",
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-const": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "warn",
|
||||
"radix": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
|
|
@ -50,6 +50,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
|
||||
needs: [ci]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
|
|
@ -61,6 +64,8 @@ jobs:
|
|||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: "pnpm"
|
||||
- name: Update npm to latest version
|
||||
run: npm install -g npm@latest
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Restore build artifacts
|
||||
|
|
@ -81,31 +86,40 @@ jobs:
|
|||
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ github.sha }}
|
||||
- name: Build release version
|
||||
- name: Set version for tagged release
|
||||
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Setting version to $VERSION for tagged release"
|
||||
cd packages/webamp && npm version $VERSION --no-git-tag-version
|
||||
cd ../ani-cursor && npm version $VERSION --no-git-tag-version
|
||||
cd ../winamp-eqf && npm version $VERSION --no-git-tag-version
|
||||
# TODO: Update version number in webampLazy.tsx if needed
|
||||
- name: Publish ani-cursor to npm
|
||||
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: |
|
||||
npm publish ${TAG} --ignore-scripts
|
||||
env:
|
||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
- name: Publish winamp-eqf to npm
|
||||
working-directory: ./packages/winamp-eqf
|
||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||
run: |
|
||||
npm publish ${TAG} --ignore-scripts
|
||||
env:
|
||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
- name: Publish webamp to npm
|
||||
working-directory: ./packages/webamp
|
||||
if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
|
||||
# Use pre-built artifacts instead of rebuilding
|
||||
run: |
|
||||
npm publish ${TAG} --ignore-scripts
|
||||
env:
|
||||
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
npm publish --tag=next --ignore-scripts --provenance
|
||||
else
|
||||
npm publish --ignore-scripts --provenance
|
||||
fi
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
node_modules
|
||||
.vscode
|
||||
.idea
|
||||
dist
|
||||
|
||||
# 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/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
|
||||
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
|
||||
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
|
||||
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
|
||||
- [`packages/skin-museum-client`](https://github.com/captbaritone/webamp/tree/master/packages/skin-museum-client): The front-end component of https://skins.webamp.org.
|
||||
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
|
||||
- [`packages/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
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
#!/bin/bash
|
||||
pnpm --filter ani-cursor build
|
||||
pnpm --filter webamp build
|
||||
pnpm --filter webamp build-library
|
||||
pnpm --filter webamp-modern build
|
||||
mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern
|
||||
24
examples/lazy/.gitignore
vendored
Normal file
24
examples/lazy/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
examples/lazy/README.md
Normal file
5
examples/lazy/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# `webamp/lazy` Example
|
||||
|
||||
Shows how it's possible to use Webamp with lazy loading and TypeScript. Uses [Vite](https://vitejs.dev/) for development and bundling.
|
||||
|
||||
Pay special attention to the versions used in `package.json` since some beta versions are required for this to work.
|
||||
13
examples/lazy/index.html
Normal file
13
examples/lazy/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Webamp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
examples/lazy/package.json
Normal file
22
examples/lazy/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "lazy",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"butterchurn": "3.0.0-beta.5",
|
||||
"butterchurn-presets": "3.0.0-beta.4",
|
||||
"jszip": "^3.10.1",
|
||||
"music-metadata": "^11.6.0",
|
||||
"webamp": "^2.2.0"
|
||||
}
|
||||
}
|
||||
57
examples/lazy/src/main.ts
Normal file
57
examples/lazy/src/main.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Webamp from "webamp/lazy";
|
||||
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
windowLayout: {
|
||||
main: { position: { left: 0, top: 0 } },
|
||||
equalizer: { position: { left: 0, top: 116 } },
|
||||
playlist: {
|
||||
position: { left: 0, top: 232 },
|
||||
size: { extraHeight: 4, extraWidth: 0 },
|
||||
},
|
||||
milkdrop: {
|
||||
position: { left: 275, top: 0 },
|
||||
size: { extraHeight: 12, extraWidth: 7 },
|
||||
},
|
||||
},
|
||||
requireJSZip: async () => {
|
||||
const JSZip = await import("jszip");
|
||||
return JSZip.default;
|
||||
},
|
||||
// @ts-ignore
|
||||
requireMusicMetadata: async () => {
|
||||
return await import("music-metadata");
|
||||
},
|
||||
__butterchurnOptions: {
|
||||
// @ts-ignore
|
||||
importButterchurn: () => import("butterchurn"),
|
||||
// @ts-ignore
|
||||
getPresets: async () => {
|
||||
const butterchurnPresets = await import(
|
||||
// @ts-ignore
|
||||
"butterchurn-presets/dist/base.js"
|
||||
);
|
||||
// Convert the presets object
|
||||
return Object.entries(butterchurnPresets.default).map(
|
||||
([name, preset]) => {
|
||||
return { name, butterchurnPresetObject: preset };
|
||||
}
|
||||
);
|
||||
},
|
||||
butterchurnOpen: true,
|
||||
},
|
||||
});
|
||||
|
||||
webamp.renderWhenReady(document.getElementById("app")!);
|
||||
1
examples/lazy/src/vite-env.d.ts
vendored
Normal file
1
examples/lazy/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
examples/lazy/tsconfig.json
Normal file
25
examples/lazy/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
import Webamp from "https://unpkg.com/webamp@^2";
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@
|
|||
</div>
|
||||
<script type="module">
|
||||
/**
|
||||
* Webamp includes a `webamp/butterchurn` entrypoint which includes the Butterchurn
|
||||
* library to enable the Milkdrop visualizer. It hasn't been included in a
|
||||
* stable version yet, until it is, you can use by installing
|
||||
* `webamp@next`.
|
||||
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
|
||||
* entrypoint which includes the Butterchurn library to enable the
|
||||
* Milkdrop visualizer.
|
||||
*/
|
||||
import Webamp from "https://unpkg.com/webamp@0.0.0-next-7cdc35b/butterchurn";
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
|
|
|
|||
13
examples/multipleMilkdropPresets/README.md
Normal file
13
examples/multipleMilkdropPresets/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Multiple Milkdrop Presets Example
|
||||
|
||||
An example of overriding the default Milkdrop presets with a custom set of presets.
|
||||
|
||||
This example fetches the Webamp bundle from a free CDN, and fetches the audio file and skin from a free CDN as well.
|
||||
|
||||
You should be able to open this local html file in your browser and see Webamp working.
|
||||
|
||||
```
|
||||
$ git clone git@github.com:captbaritone/webamp.git
|
||||
$ cd webamp
|
||||
$ open examples/multipleMilkdropPresets/index.html
|
||||
```
|
||||
45
examples/multipleMilkdropPresets/index.html
Executable file
45
examples/multipleMilkdropPresets/index.html
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="height: 100vh">
|
||||
<!-- Webamp will attempt to center itself within this div -->
|
||||
</div>
|
||||
<script type="module">
|
||||
// The `requireButterchurnPresets` config option is not yet available in a stable release.
|
||||
// for now you need to install a pre-release version of Webamp to use it.
|
||||
import Webamp from "https://unpkg.com/webamp@0.0.0-next-41cfbbb/butterchurn";
|
||||
const webamp = new Webamp({
|
||||
initialTracks: [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
// NOTE: Your audio file must be served from the same domain as your HTML
|
||||
// file, or served with permissive CORS HTTP headers:
|
||||
// https://docs.webamp.org/docs/guides/cors
|
||||
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
],
|
||||
requireButterchurnPresets() {
|
||||
return [
|
||||
{
|
||||
name: "md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.milk",
|
||||
butterchurnPresetUrl:
|
||||
"https://archive.org/cors/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.json",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// Returns a promise indicating when it's done loading.
|
||||
webamp.renderWhenReady(document.getElementById("app"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[build]
|
||||
command = "pnpm deploy"
|
||||
command = "pnpm run deploy"
|
||||
publish = "packages/webamp/dist/demo-site/"
|
||||
|
||||
# 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:all": "npx turbo run test integration-tests",
|
||||
"test:unit": "jest",
|
||||
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
|
||||
"lint": "npx turbo lint",
|
||||
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check",
|
||||
"deploy": "sh deploy.sh",
|
||||
"deploy": "npx turbo webamp#build webamp-modern#build --concurrency 1 && mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern",
|
||||
"format": "prettier --write '**/*.{js,ts,tsx}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -43,5 +43,10 @@
|
|||
"prettier": {
|
||||
"trailingComma": "es5"
|
||||
},
|
||||
"version": "0.0.0-next-87012d8d"
|
||||
"version": "0.0.0-next-87012d8d",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts,js",
|
||||
"test": "jest",
|
||||
"prepublish": "tsc"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,32 +1,28 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
node: true,
|
||||
es2021: true,
|
||||
jest: true,
|
||||
},
|
||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
// "no-console": "warn",
|
||||
// Disable rules that conflict with the project's style
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/no-require-imports": "off", // Allow require() in JS files
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
// Override the base no-shadow rule since it conflicts with TypeScript
|
||||
"no-shadow": "off",
|
||||
// Relax rules for this project's existing style
|
||||
camelcase: "off",
|
||||
"dot-notation": "off",
|
||||
eqeqeq: "off",
|
||||
"no-undef-init": "off",
|
||||
"no-return-await": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-div-regex": "off",
|
||||
"guard-for-in": "off",
|
||||
"prefer-template": "off",
|
||||
"no-else-return": "off",
|
||||
"prefer-const": "off",
|
||||
"new-cap": "off",
|
||||
},
|
||||
ignorePatterns: ["dist/**"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
|
|||
await setHashesForSkin(skin);
|
||||
|
||||
// Disable while we figure out our quota
|
||||
// await Skins.updateSearchIndex(ctx, md5);
|
||||
await Skins.updateSearchIndex(ctx, md5);
|
||||
|
||||
return { md5, status: "ADDED", skinType: "CLASSIC" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import { Router } from "express";
|
||||
import { createYoga, YogaInitialContext } from "graphql-yoga";
|
||||
|
||||
// import DEFAULT_QUERY from "./defaultQuery";
|
||||
import { getSchema } from "./schema";
|
||||
import UserContext from "../../data/UserContext.js";
|
||||
|
||||
/** @gqlContext */
|
||||
|
|
@ -12,18 +7,3 @@ export type Ctx = Express.Request;
|
|||
export function getUserContext(ctx: Ctx): UserContext {
|
||||
return ctx.ctx;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
const yoga = createYoga({
|
||||
schema: getSchema(),
|
||||
context: (ctx: YogaInitialContext) => {
|
||||
// @ts-expect-error
|
||||
return ctx.req;
|
||||
},
|
||||
});
|
||||
|
||||
// Bind GraphQL Yoga to the graphql endpoint to avoid rendering the playground on any path
|
||||
router.use("", yoga);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { Int } from "grats";
|
||||
import { knex } from "../../../db";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import ClassicSkinResolver from "./ClassicSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import { ISkin } from "./CommonSkinResolver";
|
||||
import { SkinRow } from "../../../types";
|
||||
|
||||
/**
|
||||
* Connection for bulk download skin metadata
|
||||
* @gqlType
|
||||
*/
|
||||
export class BulkDownloadConnection {
|
||||
_offset: number;
|
||||
_first: number;
|
||||
|
||||
constructor(first: number, offset: number) {
|
||||
this._first = first;
|
||||
this._offset = offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of skins available for download
|
||||
* @gqlField
|
||||
*/
|
||||
async totalCount(): Promise<Int> {
|
||||
// Get count of both classic and modern skins
|
||||
const [classicResult, modernResult] = await Promise.all([
|
||||
knex("skins").where({ skin_type: 1 }).count("* as count"),
|
||||
knex("skins").where({ skin_type: 2 }).count("* as count"),
|
||||
]);
|
||||
|
||||
const classicCount = Number(classicResult[0].count);
|
||||
const modernCount = Number(modernResult[0].count);
|
||||
|
||||
return classicCount + modernCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated total size in bytes (approximation for progress indication)
|
||||
* @gqlField
|
||||
*/
|
||||
async estimatedSizeBytes(): Promise<string> {
|
||||
const totalCount = await this.totalCount();
|
||||
// Rough estimate: average skin is ~56KB
|
||||
return (totalCount * 56 * 1024).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* List of skin metadata for bulk download
|
||||
* @gqlField
|
||||
*/
|
||||
async nodes(ctx: UserContext): Promise<Array<ISkin>> {
|
||||
// Get skins ordered by skin_type (classic first, then modern) and id for consistency
|
||||
const skins = await knex<SkinRow>("skins")
|
||||
.select(["id", "md5", "skin_type", "emails"])
|
||||
.orderBy([{ column: "skins.id", order: "asc" }])
|
||||
.where({ skin_type: 1 })
|
||||
.orWhere({ skin_type: 2 })
|
||||
.where((builder) => {
|
||||
builder.where({ skin_type: 1 }).orWhere({ skin_type: 2 });
|
||||
})
|
||||
.limit(this._first)
|
||||
.offset(this._offset);
|
||||
|
||||
return skins.map((skinRow) => {
|
||||
const skinModel = new SkinModel(ctx, skinRow);
|
||||
|
||||
if (skinRow.skin_type === 1) {
|
||||
return new ClassicSkinResolver(skinModel);
|
||||
} else if (skinRow.skin_type === 2) {
|
||||
return new ModernSkinResolver(skinModel);
|
||||
} else {
|
||||
throw new Error("Expected classic or modern skin");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for bulk downloading all skins in the museum
|
||||
* @gqlQueryField
|
||||
*/
|
||||
export function bulkDownload({
|
||||
first = 1000,
|
||||
offset = 0,
|
||||
}: {
|
||||
first?: Int;
|
||||
offset?: Int;
|
||||
}): BulkDownloadConnection {
|
||||
if (first > 10000) {
|
||||
throw new Error("Maximum limit is 10000 for bulk download");
|
||||
}
|
||||
return new BulkDownloadConnection(first, offset);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ISkin } from "./CommonSkinResolver";
|
||||
import { NodeResolver, toId } from "./NodeResolver";
|
||||
import ReviewResolver from "./ReviewResolver";
|
||||
import path from "path";
|
||||
import { ID, Int } from "grats";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
|
||||
|
|
@ -24,11 +23,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
|||
return toId(this.__typename, this.md5());
|
||||
}
|
||||
async filename(normalize_extension?: boolean): Promise<string> {
|
||||
const filename = await this._model.getFileName();
|
||||
if (normalize_extension) {
|
||||
return path.parse(filename).name + ".wsz";
|
||||
}
|
||||
return filename;
|
||||
return await this._model.getFileName(normalize_extension);
|
||||
}
|
||||
|
||||
museum_url(): string {
|
||||
|
|
@ -46,7 +41,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
|
|||
nsfw(): Promise<boolean> {
|
||||
return this._model.getIsNsfw();
|
||||
}
|
||||
average_color(): string {
|
||||
average_color(): string | null {
|
||||
return this._model.getAverageColor();
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -69,19 +69,34 @@ export function id(skin: ISkin): ID {
|
|||
* has been uploaded under multiple names. Here we just pick one.
|
||||
* @gqlField
|
||||
*/
|
||||
export function filename(
|
||||
export async function filename(
|
||||
skin: ISkin,
|
||||
{
|
||||
normalize_extension = false,
|
||||
include_museum_id = false,
|
||||
}: {
|
||||
/**
|
||||
* If true, the the correct file extension (.wsz or .wal) will be .
|
||||
* Otherwise, the original user-uploaded file extension will be used.
|
||||
*/
|
||||
normalize_extension?: boolean;
|
||||
/**
|
||||
* If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
*/
|
||||
include_museum_id?: boolean;
|
||||
}
|
||||
): Promise<string> {
|
||||
return skin.filename(normalize_extension);
|
||||
const baseFilename = await skin.filename(normalize_extension);
|
||||
|
||||
if (!include_museum_id) {
|
||||
return baseFilename;
|
||||
}
|
||||
|
||||
const museumId = skin._model.getId();
|
||||
const segments = baseFilename.split(".");
|
||||
const fileExtension = segments.pop();
|
||||
|
||||
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Schema generated by Grats (https://grats.capt.dev)
|
||||
# Do not manually edit. Regenerate by running `npx grats`.
|
||||
|
||||
"""
|
||||
Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
|
||||
In all other cases, the position is non-null.
|
||||
|
|
@ -92,6 +93,10 @@ interface Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -164,6 +169,16 @@ type ArchiveFile {
|
|||
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"""
|
||||
type ClassicSkin implements Node & Skin {
|
||||
"""List of files contained within the skin's .wsz archive"""
|
||||
|
|
@ -177,6 +192,10 @@ type ClassicSkin implements Node & Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -307,6 +326,10 @@ type ModernSkin implements Node & Skin {
|
|||
has been uploaded under multiple names. Here we just pick one.
|
||||
"""
|
||||
filename(
|
||||
"""
|
||||
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
|
||||
"""
|
||||
include_museum_id: Boolean! = false
|
||||
"""
|
||||
If true, the the correct file extension (.wsz or .wal) will be .
|
||||
Otherwise, the original user-uploaded file extension will be used.
|
||||
|
|
@ -384,6 +407,8 @@ type Mutation {
|
|||
}
|
||||
|
||||
type Query {
|
||||
"""Get metadata for bulk downloading all skins in the museum"""
|
||||
bulkDownload(first: Int! = 1000, offset: Int! = 0): BulkDownloadConnection @semanticNonNull
|
||||
"""
|
||||
Fetch archive file by it's MD5 hash
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
* Executable schema generated by Grats (https://grats.capt.dev)
|
||||
* Do not manually edit. Regenerate by running `npx grats`.
|
||||
*/
|
||||
import { defaultFieldResolver, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLBoolean, GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNonNull, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
|
||||
import { getUserContext as getUserContext } from "./index";
|
||||
|
||||
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
|
||||
import { getUserContext } from "./index";
|
||||
import { bulkDownload as queryBulkDownloadResolver } from "./resolvers/BulkDownloadConnection";
|
||||
import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./../../data/ArchiveFileModel";
|
||||
import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./../../data/IaItemModel";
|
||||
import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver, search_classic_skins as querySearch_classic_skinsResolver, search_skins as querySearch_skinsResolver, skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver";
|
||||
|
|
@ -26,6 +28,75 @@ async function assertNonNull<T>(value: T | Promise<T>): Promise<T> {
|
|||
return awaited;
|
||||
}
|
||||
export function getSchema(): GraphQLSchema {
|
||||
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "ArchiveFile",
|
||||
description: "A file found within a Winamp Skin's .wsz archive",
|
||||
fields() {
|
||||
return {
|
||||
date: {
|
||||
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
||||
name: "date",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsoDate());
|
||||
}
|
||||
},
|
||||
file_md5: {
|
||||
description: "The md5 hash of the file within the archive",
|
||||
name: "file_md5",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileMd5());
|
||||
}
|
||||
},
|
||||
filename: {
|
||||
description: "Filename of the file within the archive",
|
||||
name: "filename",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileName());
|
||||
}
|
||||
},
|
||||
is_directory: {
|
||||
description: "Is the file a directory?",
|
||||
name: "is_directory",
|
||||
type: GraphQLBoolean,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsDirectory());
|
||||
}
|
||||
},
|
||||
size: {
|
||||
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
|
||||
name: "size",
|
||||
type: GraphQLInt,
|
||||
resolve(source) {
|
||||
return source.getFileSize();
|
||||
}
|
||||
},
|
||||
skin: {
|
||||
description: "The skin in which this file was found",
|
||||
name: "skin",
|
||||
type: SkinType
|
||||
},
|
||||
text_content: {
|
||||
description: "The content of the file, if it's a text file",
|
||||
name: "text_content",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getTextContent();
|
||||
}
|
||||
},
|
||||
url: {
|
||||
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
|
||||
name: "url",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getUrl();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "InternetArchiveItem",
|
||||
fields() {
|
||||
|
|
@ -187,9 +258,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -253,70 +328,33 @@ export function getSchema(): GraphQLSchema {
|
|||
};
|
||||
}
|
||||
});
|
||||
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "ArchiveFile",
|
||||
description: "A file found within a Winamp Skin's .wsz archive",
|
||||
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "BulkDownloadConnection",
|
||||
description: "Connection for bulk download skin metadata",
|
||||
fields() {
|
||||
return {
|
||||
date: {
|
||||
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
|
||||
name: "date",
|
||||
estimatedSizeBytes: {
|
||||
description: "Estimated total size in bytes (approximation for progress indication)",
|
||||
name: "estimatedSizeBytes",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsoDate());
|
||||
resolve(source, args, context, info) {
|
||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||
}
|
||||
},
|
||||
file_md5: {
|
||||
description: "The md5 hash of the file within the archive",
|
||||
name: "file_md5",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileMd5());
|
||||
nodes: {
|
||||
description: "List of skin metadata for bulk download",
|
||||
name: "nodes",
|
||||
type: new GraphQLList(new GraphQLNonNull(SkinType)),
|
||||
resolve(source, _args, context) {
|
||||
return assertNonNull(source.nodes(getUserContext(context)));
|
||||
}
|
||||
},
|
||||
filename: {
|
||||
description: "Filename of the file within the archive",
|
||||
name: "filename",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getFileName());
|
||||
}
|
||||
},
|
||||
is_directory: {
|
||||
description: "Is the file a directory?",
|
||||
name: "is_directory",
|
||||
type: GraphQLBoolean,
|
||||
resolve(source) {
|
||||
return assertNonNull(source.getIsDirectory());
|
||||
}
|
||||
},
|
||||
size: {
|
||||
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
|
||||
name: "size",
|
||||
totalCount: {
|
||||
description: "Total number of skins available for download",
|
||||
name: "totalCount",
|
||||
type: GraphQLInt,
|
||||
resolve(source) {
|
||||
return source.getFileSize();
|
||||
}
|
||||
},
|
||||
skin: {
|
||||
description: "The skin in which this file was found",
|
||||
name: "skin",
|
||||
type: SkinType
|
||||
},
|
||||
text_content: {
|
||||
description: "The content of the file, if it's a text file",
|
||||
name: "text_content",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getTextContent();
|
||||
}
|
||||
},
|
||||
url: {
|
||||
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
|
||||
name: "url",
|
||||
type: GraphQLString,
|
||||
resolve(source) {
|
||||
return source.getUrl();
|
||||
resolve(source, args, context, info) {
|
||||
return assertNonNull(defaultFieldResolver(source, args, context, info));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -382,9 +420,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -545,9 +587,13 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "filename",
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
include_museum_id: {
|
||||
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
},
|
||||
normalize_extension: {
|
||||
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
|
||||
name: "normalize_extension",
|
||||
type: new GraphQLNonNull(GraphQLBoolean),
|
||||
defaultValue: false
|
||||
}
|
||||
|
|
@ -903,13 +949,30 @@ export function getSchema(): GraphQLSchema {
|
|||
name: "Query",
|
||||
fields() {
|
||||
return {
|
||||
bulkDownload: {
|
||||
description: "Get metadata for bulk downloading all skins in the museum",
|
||||
name: "bulkDownload",
|
||||
type: BulkDownloadConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 1000
|
||||
},
|
||||
offset: {
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
resolve(_source, args) {
|
||||
return assertNonNull(queryBulkDownloadResolver(args));
|
||||
}
|
||||
},
|
||||
fetch_archive_file_by_md5: {
|
||||
description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.",
|
||||
name: "fetch_archive_file_by_md5",
|
||||
type: ArchiveFileType,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -923,7 +986,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: InternetArchiveItemType,
|
||||
args: {
|
||||
identifier: {
|
||||
name: "identifier",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -937,7 +999,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: SkinType,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -951,7 +1012,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: TweetType,
|
||||
args: {
|
||||
url: {
|
||||
name: "url",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -973,12 +1033,10 @@ export function getSchema(): GraphQLSchema {
|
|||
type: ModernSkinsConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
}
|
||||
|
|
@ -993,7 +1051,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: NodeType,
|
||||
args: {
|
||||
id: {
|
||||
name: "id",
|
||||
type: new GraphQLNonNull(GraphQLID)
|
||||
}
|
||||
},
|
||||
|
|
@ -1007,17 +1064,14 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(ClassicSkinType),
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
query: {
|
||||
name: "query",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1031,17 +1085,14 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinType),
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
query: {
|
||||
name: "query",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1063,21 +1114,17 @@ export function getSchema(): GraphQLSchema {
|
|||
type: SkinsConnectionType,
|
||||
args: {
|
||||
filter: {
|
||||
name: "filter",
|
||||
type: SkinsFilterOptionType
|
||||
},
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
sort: {
|
||||
name: "sort",
|
||||
type: SkinsSortOptionType
|
||||
}
|
||||
},
|
||||
|
|
@ -1099,17 +1146,14 @@ export function getSchema(): GraphQLSchema {
|
|||
type: TweetsConnectionType,
|
||||
args: {
|
||||
first: {
|
||||
name: "first",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 10
|
||||
},
|
||||
offset: {
|
||||
name: "offset",
|
||||
type: new GraphQLNonNull(GraphQLInt),
|
||||
defaultValue: 0
|
||||
},
|
||||
sort: {
|
||||
name: "sort",
|
||||
type: TweetsSortOptionType
|
||||
}
|
||||
},
|
||||
|
|
@ -1123,7 +1167,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinUploadType),
|
||||
args: {
|
||||
ids: {
|
||||
name: "ids",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1138,7 +1181,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(SkinUploadType),
|
||||
args: {
|
||||
md5s: {
|
||||
name: "md5s",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1205,7 +1247,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: new GraphQLList(UploadUrlType),
|
||||
args: {
|
||||
files: {
|
||||
name: "files",
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
|
||||
}
|
||||
},
|
||||
|
|
@ -1219,11 +1260,9 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
id: {
|
||||
name: "id",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
},
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1244,7 +1283,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1258,7 +1296,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1272,7 +1309,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1286,7 +1322,6 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
md5: {
|
||||
name: "md5",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
}
|
||||
},
|
||||
|
|
@ -1300,15 +1335,12 @@ export function getSchema(): GraphQLSchema {
|
|||
type: GraphQLBoolean,
|
||||
args: {
|
||||
email: {
|
||||
name: "email",
|
||||
type: GraphQLString
|
||||
},
|
||||
message: {
|
||||
name: "message",
|
||||
type: new GraphQLNonNull(GraphQLString)
|
||||
},
|
||||
url: {
|
||||
name: "url",
|
||||
type: GraphQLString
|
||||
}
|
||||
},
|
||||
|
|
@ -1328,8 +1360,19 @@ export function getSchema(): GraphQLSchema {
|
|||
}
|
||||
});
|
||||
return new GraphQLSchema({
|
||||
directives: [...specifiedDirectives, new GraphQLDirective({
|
||||
name: "semanticNonNull",
|
||||
locations: [DirectiveLocation.FIELD_DEFINITION],
|
||||
description: "Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.\nIn all other cases, the position is non-null.\n\nTools doing code generation may use this information to generate the position as non-null if field errors are handled out of band:\n\n```graphql\ntype User {\n # email is semantically non-null and can be generated as non-null by error-handling clients.\n email: String @semanticNonNull\n}\n```\n\nThe `levels` argument indicates what levels are semantically non null in case of lists:\n\n```graphql\ntype User {\n # friends is semantically non null\n friends: [User] @semanticNonNull # same as @semanticNonNull(levels: [0])\n\n # every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [1])\n\n # friends as well as every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [0, 1])\n}\n```\n\n`levels` are zero indexed.\nPassing a negative level or a level greater than the list dimension is an error.",
|
||||
args: {
|
||||
levels: {
|
||||
type: new GraphQLList(GraphQLInt),
|
||||
defaultValue: [0]
|
||||
}
|
||||
}
|
||||
})],
|
||||
query: QueryType,
|
||||
mutation: MutationType,
|
||||
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
|
||||
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, BulkDownloadConnectionType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60;
|
|||
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject("timeout"), duration)
|
||||
),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject("timeout"), duration);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/about/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/feedback/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
5
packages/skin-database/app/(legacy)/layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from "../App";
|
||||
|
||||
export default function Layout() {
|
||||
return <App />;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import App from "../../../App";
|
||||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "../skinMetadata";
|
||||
|
||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|||
}
|
||||
|
||||
export default function Page() {
|
||||
return <App />;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import App from "../../App";
|
||||
import type { Metadata } from "next";
|
||||
import { generateSkinPageMetadata } from "./skinMetadata";
|
||||
|
||||
|
|
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|||
}
|
||||
|
||||
export default function Page() {
|
||||
return <App />;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Metadata } from "next";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
import SkinModel from "../../../../data/SkinModel";
|
||||
import UserContext from "../../../../data/UserContext";
|
||||
|
||||
export async function generateSkinPageMetadata(
|
||||
hash: string
|
||||
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
3
packages/skin-database/app/(legacy)/upload/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
330
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Smartphone,
|
||||
Info,
|
||||
Grid3x3,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
import {
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
unstable_ViewTransition as ViewTransition,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
export default function BottomMenuBar() {
|
||||
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const toggleHamburger = () => {
|
||||
setIsHamburgerOpen(!isHamburgerOpen);
|
||||
};
|
||||
|
||||
// Close hamburger menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
isHamburgerOpen
|
||||
) {
|
||||
setIsHamburgerOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHamburgerOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isHamburgerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hamburger Menu Overlay */}
|
||||
{isHamburgerOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "4.5rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.98)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderBottom: "none",
|
||||
boxShadow: "0 -4px 12px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<div ref={menuRef}>
|
||||
<HamburgerMenuItem
|
||||
href="/scroll/about"
|
||||
icon={<Info size={20} />}
|
||||
label="About"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="/scroll/feedback"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="Feedback"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="https://github.com/captbaritone/webamp/"
|
||||
icon={<Github size={20} />}
|
||||
label="GitHub"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Menu Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.95)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
padding: "0.75rem 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
|
||||
display: "flex",
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MenuButton
|
||||
href="/scroll"
|
||||
icon={<Grid3x3 size={24} />}
|
||||
label="Grid"
|
||||
isActive={pathname === "/scroll"}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/scroll/skin"
|
||||
icon={<Smartphone size={24} />}
|
||||
label="Feed"
|
||||
isActive={pathname.startsWith("/scroll/skin")}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/upload"
|
||||
icon={<Upload size={24} />}
|
||||
label="Upload"
|
||||
isActive={pathname === "/upload"}
|
||||
/>
|
||||
<MenuButton
|
||||
icon={<Menu size={24} />}
|
||||
label="Menu"
|
||||
onClick={toggleHamburger}
|
||||
isButton
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuButtonProps = {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isButton?: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function MenuButton({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
isButton = false,
|
||||
isActive = false,
|
||||
onClick,
|
||||
}: MenuButtonProps) {
|
||||
const touchTargetSize = "3.0rem";
|
||||
|
||||
const containerStyle = {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.25rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "color 0.2s ease",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
position: "relative" as const,
|
||||
width: touchTargetSize,
|
||||
minWidth: touchTargetSize,
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#fff";
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Active indicator line */}
|
||||
{isActive && (
|
||||
<ViewTransition name="footer-menu-active">
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-0.75rem",
|
||||
left: 0,
|
||||
width: touchTargetSize,
|
||||
height: "1px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</ViewTransition>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
></span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isButton) {
|
||||
return (
|
||||
<button
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
title={label}
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type HamburgerMenuItemProps = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
function HamburgerMenuItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
external = false,
|
||||
}: HamburgerMenuItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
padding: "1rem 1.5rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s ease, color 0.2s ease",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
103
packages/skin-database/app/(modern)/scroll/Events.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use server";
|
||||
|
||||
import { knex } from "../../../db";
|
||||
import { markAsNSFW } from "../../../data/skins";
|
||||
import UserContext from "../../../data/UserContext";
|
||||
|
||||
export async function logUserEvent(sessionId: string, event: UserEvent) {
|
||||
const timestamp = Date.now();
|
||||
|
||||
console.log("Logging user event:", {
|
||||
sessionId,
|
||||
timestamp,
|
||||
event,
|
||||
});
|
||||
|
||||
await knex("user_log_events").insert({
|
||||
session_id: sessionId,
|
||||
timestamp: timestamp,
|
||||
metadata: JSON.stringify(event),
|
||||
});
|
||||
|
||||
// If this is a NSFW report, call the existing infrastructure
|
||||
if (event.type === "skin_flag_nsfw") {
|
||||
// Create an anonymous user context for the report
|
||||
const ctx = new UserContext();
|
||||
await markAsNSFW(ctx, event.skinMd5);
|
||||
}
|
||||
}
|
||||
|
||||
export type UserEvent =
|
||||
| {
|
||||
type: "session_start";
|
||||
}
|
||||
| {
|
||||
type: "session_end";
|
||||
reason: "unmount" | "before_unload";
|
||||
}
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
| {
|
||||
type: "skin_view";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_view_start";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_view_end";
|
||||
skinMd5: string;
|
||||
durationMs: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_start";
|
||||
offset: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_success";
|
||||
offset: number;
|
||||
}
|
||||
| {
|
||||
type: "skins_fetch_failure";
|
||||
offset: number;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: "readme_expand";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_download";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "skin_like";
|
||||
skinMd5: string;
|
||||
liked: boolean;
|
||||
}
|
||||
| {
|
||||
type: "skin_flag_nsfw";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_open";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_success";
|
||||
skinMd5: string;
|
||||
}
|
||||
| {
|
||||
type: "share_failure";
|
||||
skinMd5: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: "menu_click";
|
||||
menuItem: string;
|
||||
}
|
||||
| {
|
||||
type: "scroll_hint_shown";
|
||||
};
|
||||
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal file
344
packages/skin-database/app/(modern)/scroll/Grid.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
unstable_ViewTransition as ViewTransition,
|
||||
useEffect,
|
||||
} from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
import { useWindowSize } from "../../../legacy-client/src/hooks";
|
||||
import {
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
MOBILE_MAX_WIDTH,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
|
||||
import { searchSkins as performAlgoliaSearch } from "./algoliaClient";
|
||||
|
||||
// Simple utility to get screenshot URL (avoiding server-side import)
|
||||
function getScreenshotUrl(md5: string): string {
|
||||
return `https://r2.webampskins.org/screenshots/${md5}.png`;
|
||||
}
|
||||
|
||||
type CellData = {
|
||||
skins: GridSkin[];
|
||||
columnCount: number;
|
||||
width: number;
|
||||
height: number;
|
||||
loadMoreSkins: (startIndex: number) => Promise<void>;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
data,
|
||||
}: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
data: CellData;
|
||||
}) {
|
||||
const { skins, width, height, columnCount } = data;
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
data.loadMoreSkins(index);
|
||||
const skin = skins[index];
|
||||
|
||||
if (!skin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={{ width, height, position: "relative" }}>
|
||||
<Link href={`/scroll/skin/${skin.md5}`}>
|
||||
<ViewTransition name={`skin-${skin.md5}`}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</ViewTransition>
|
||||
</Link>
|
||||
{skin.nsfw && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
NSFW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SkinTableProps = {
|
||||
initialSkins: GridSkin[];
|
||||
initialTotal: number;
|
||||
};
|
||||
|
||||
export default function SkinTable({
|
||||
initialSkins,
|
||||
initialTotal,
|
||||
}: SkinTableProps) {
|
||||
const { windowWidth, windowHeight } = useWindowSize();
|
||||
|
||||
// Search input state - separate input value from actual search query
|
||||
const [inputValue, setInputValue] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("q") || "";
|
||||
});
|
||||
|
||||
// State for browsing mode
|
||||
const [browseSkins, setBrowseSkins] = useState<GridSkin[]>(initialSkins);
|
||||
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([0]));
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// State for search mode
|
||||
const [searchSkins, setSearchSkins] = useState<GridSkin[]>([]);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [_, setSearchIsPending] = useState(false);
|
||||
|
||||
// Debounce timer ref
|
||||
|
||||
// Determine which mode we're in based on actual search query, not input
|
||||
const isSearchMode = inputValue.trim().length > 0;
|
||||
const skins = isSearchMode ? searchSkins : browseSkins;
|
||||
const total = isSearchMode ? searchSkins.length : initialTotal;
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setInputValue(query);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const query = inputValue;
|
||||
const newUrl = query.trim() === "" ? "/scroll/" : `/scroll/?q=${query}`;
|
||||
// window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
|
||||
// If query is empty, clear results immediately
|
||||
if (!query || query.trim().length === 0) {
|
||||
setSearchSkins([]);
|
||||
setSearchError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchResults() {
|
||||
try {
|
||||
setSearchIsPending(true);
|
||||
const result = await performAlgoliaSearch(query);
|
||||
const hits = result.hits as Array<{
|
||||
objectID: string;
|
||||
fileName: string;
|
||||
nsfw?: boolean;
|
||||
}>;
|
||||
const searchResults: GridSkin[] = hits.map((hit) => ({
|
||||
md5: hit.objectID,
|
||||
screenshotUrl: getScreenshotUrl(hit.objectID),
|
||||
fileName: hit.fileName,
|
||||
nsfw: hit.nsfw ?? false,
|
||||
}));
|
||||
setSearchSkins(searchResults);
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
setSearchError("Search failed. Please try again.");
|
||||
setSearchSkins([]);
|
||||
} finally {
|
||||
setSearchIsPending(false);
|
||||
}
|
||||
}
|
||||
fetchResults();
|
||||
}, [inputValue]);
|
||||
|
||||
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9));
|
||||
const columnWidth = windowWidth / columnCount;
|
||||
const rowHeight = columnWidth * SKIN_RATIO;
|
||||
const pageSize = 50; // Number of skins to load per page
|
||||
|
||||
const loadMoreSkins = useCallback(
|
||||
async (startIndex: number) => {
|
||||
// Don't load more in search mode
|
||||
if (isSearchMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = Math.floor(startIndex / pageSize);
|
||||
|
||||
// Don't reload if we already have this page
|
||||
if (loadedPages.has(pageNumber) || isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingRef.current = true;
|
||||
try {
|
||||
const offset = pageNumber * pageSize;
|
||||
const newSkins = await getMuseumPageSkins(offset, pageSize);
|
||||
setBrowseSkins((prev) => [...prev, ...newSkins]);
|
||||
setLoadedPages((prev) => new Set([...prev, pageNumber]));
|
||||
} catch (error) {
|
||||
console.error("Failed to load skins:", error);
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[loadedPages, pageSize, isSearchMode]
|
||||
);
|
||||
|
||||
const itemKey = useCallback(
|
||||
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const skin = skins[index];
|
||||
return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`;
|
||||
},
|
||||
[columnCount, skins]
|
||||
);
|
||||
|
||||
const gridRef = React.useRef<any>(null);
|
||||
const itemRef = React.useRef<number>(0);
|
||||
|
||||
const onScroll = useMemo(() => {
|
||||
const half = Math.round(columnCount / 2);
|
||||
return (scrollData: { scrollTop: number }) => {
|
||||
itemRef.current =
|
||||
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
|
||||
};
|
||||
}, [columnCount, rowHeight]);
|
||||
|
||||
const itemData: CellData = useMemo(
|
||||
() => ({
|
||||
skins,
|
||||
columnCount,
|
||||
width: columnWidth,
|
||||
height: rowHeight,
|
||||
loadMoreSkins,
|
||||
}),
|
||||
[skins, columnCount, columnWidth, rowHeight, loadMoreSkins]
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="infinite-skins">
|
||||
{/* Floating Search Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "4.25rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "calc(100% - 2rem)",
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
padding: "0 1rem",
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search skins..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
paddingRight: "1rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.55)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "9999px",
|
||||
color: "#fff",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
transition: "padding-right 0.2s ease",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
|
||||
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
|
||||
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{isSearchMode && searchError && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
color: "#ff6b6b",
|
||||
}}
|
||||
>
|
||||
{searchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty Results */}
|
||||
{isSearchMode && !searchError && skins.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
No results found for "{inputValue}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid - show when browsing or when we have results (even while pending) */}
|
||||
{(!isSearchMode || (!searchError && skins.length > 0)) && (
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
itemKey={itemKey}
|
||||
itemData={itemData}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={windowHeight}
|
||||
rowCount={Math.ceil(total / columnCount)}
|
||||
rowHeight={rowHeight}
|
||||
width={windowWidth}
|
||||
overscanRowsCount={5}
|
||||
onScroll={onScroll}
|
||||
style={{ overflowY: "scroll" }}
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
memo,
|
||||
} from "react";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import {
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
|
||||
type Props = {
|
||||
initialSkins: ClientSkin[];
|
||||
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type CellProps = {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
data: {
|
||||
skins: ClientSkin[];
|
||||
columnCount: number;
|
||||
requestSkinsIfNeeded: (index: number) => void;
|
||||
};
|
||||
};
|
||||
|
||||
// Extract Cell as a separate component so we can use hooks
|
||||
const GridCell = memo(({ columnIndex, rowIndex, style, data }: CellProps) => {
|
||||
const { skins, columnCount, requestSkinsIfNeeded } = data;
|
||||
const router = useRouter();
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const skin = skins[index];
|
||||
|
||||
// Request more skins if this cell needs data
|
||||
useEffect(() => {
|
||||
if (!skin) {
|
||||
requestSkinsIfNeeded(index);
|
||||
}
|
||||
}, [skin, index, requestSkinsIfNeeded]);
|
||||
|
||||
if (!skin) {
|
||||
return <div style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: "2px",
|
||||
boxSizing: "border-box",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
router.push(`/scroll/skin/${skin.md5}`);
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
imageRendering: "pixelated",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GridCell.displayName = "GridCell";
|
||||
|
||||
// Calculate grid dimensions based on window width
|
||||
// Skins will be scaled to fill horizontally across multiple columns
|
||||
function getGridDimensions(windowWidth: number) {
|
||||
const scale = 1.0; // Can be adjusted for different sizes
|
||||
const columnCount = Math.max(
|
||||
1,
|
||||
Math.floor(windowWidth / (SCREENSHOT_WIDTH * scale))
|
||||
);
|
||||
const columnWidth = windowWidth / columnCount;
|
||||
const rowHeight = columnWidth * SKIN_RATIO;
|
||||
return { columnWidth, rowHeight, columnCount };
|
||||
}
|
||||
|
||||
export default function InfiniteScrollGrid({
|
||||
initialSkins,
|
||||
getSkins,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(0);
|
||||
const [windowHeight, setWindowHeight] = useState(0);
|
||||
const gridRef = useRef<Grid>(null);
|
||||
const requestedIndicesRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Track window size
|
||||
useLayoutEffect(() => {
|
||||
function updateSize() {
|
||||
setWindowWidth(window.innerWidth);
|
||||
setWindowHeight(window.innerHeight);
|
||||
}
|
||||
updateSize();
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
// Scroll to top when window width changes (column count changes)
|
||||
useEffect(() => {
|
||||
if (gridRef.current && windowWidth > 0) {
|
||||
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
|
||||
}
|
||||
}, [windowWidth]);
|
||||
|
||||
// Function to request more skins when a cell needs data
|
||||
const requestSkinsIfNeeded = useCallback(
|
||||
(index: number) => {
|
||||
// Only fetch if this index is beyond our current data
|
||||
if (index >= skins.length) {
|
||||
// Calculate which batch this index belongs to
|
||||
const batchSize = 50; // Fetch in batches
|
||||
const batchStart = Math.floor(skins.length / batchSize) * batchSize;
|
||||
|
||||
// Only fetch if we haven't already requested this batch
|
||||
if (!requestedIndicesRef.current.has(batchStart) && !fetching) {
|
||||
requestedIndicesRef.current.add(batchStart);
|
||||
setFetching(true);
|
||||
getSkins(sessionId, batchStart)
|
||||
.then((newSkins) => {
|
||||
setSkins((prevSkins) => [...prevSkins, ...newSkins]);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch(() => {
|
||||
requestedIndicesRef.current.delete(batchStart);
|
||||
setFetching(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[skins.length, fetching, sessionId, getSkins]
|
||||
);
|
||||
|
||||
const { columnWidth, rowHeight, columnCount } =
|
||||
getGridDimensions(windowWidth);
|
||||
|
||||
if (windowWidth === 0 || windowHeight === 0) {
|
||||
return null; // Don't render until we have window dimensions
|
||||
}
|
||||
|
||||
const rowCount = Math.ceil(skins.length / columnCount);
|
||||
|
||||
const itemData = {
|
||||
skins,
|
||||
columnCount,
|
||||
requestSkinsIfNeeded,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: "#1a1a1a" }}>
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={windowHeight}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={windowWidth}
|
||||
itemData={itemData}
|
||||
overscanRowCount={2}
|
||||
style={{
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{GridCell}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ReactNode } from "react";
|
||||
import { Heart, Share2, Flag, Download } from "lucide-react";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import { logUserEvent } from "./Events";
|
||||
|
||||
type Props = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function SkinActionIcons({ skin, sessionId }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "1rem",
|
||||
bottom: "2rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
paddingBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<LikeButton skin={skin} sessionId={sessionId} />
|
||||
<ShareButton skin={skin} sessionId={sessionId} />
|
||||
<FlagButton skin={skin} sessionId={sessionId} />
|
||||
<DownloadButton skin={skin} sessionId={sessionId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Implementation details below
|
||||
|
||||
type ButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
opacity?: number;
|
||||
"aria-label": string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Button({
|
||||
onClick,
|
||||
disabled = false,
|
||||
opacity = 1,
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
opacity,
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LikeButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function LikeButton({ skin, sessionId }: LikeButtonProps) {
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(skin.likeCount);
|
||||
|
||||
const handleLike = async () => {
|
||||
const newLikedState = !isLiked;
|
||||
setIsLiked(newLikedState);
|
||||
|
||||
// Optimistically update the like count
|
||||
setLikeCount((prevCount) =>
|
||||
newLikedState ? prevCount + 1 : prevCount - 1
|
||||
);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_like",
|
||||
skinMd5: skin.md5,
|
||||
liked: newLikedState,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleLike} aria-label="Like">
|
||||
<Heart
|
||||
size={32}
|
||||
color="white"
|
||||
fill={isLiked ? "white" : "none"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{likeCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{likeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type ShareButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function ShareButton({ skin, sessionId }: ShareButtonProps) {
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_open",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
|
||||
await navigator.share({
|
||||
title: skin.fileName,
|
||||
text: `Check out this Winamp skin: ${skin.fileName}`,
|
||||
url: skin.shareUrl,
|
||||
});
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_success",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled or share failed
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
console.error("Share failed:", error);
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_failure",
|
||||
skinMd5: skin.md5,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(skin.shareUrl);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "share_success",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
alert("Share link copied to clipboard!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleShare} aria-label="Share">
|
||||
<Share2 size={32} color="white" strokeWidth={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type FlagButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function FlagButton({ skin, sessionId }: FlagButtonProps) {
|
||||
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
|
||||
|
||||
const handleFlagNsfw = async () => {
|
||||
if (isFlagged) return; // Only allow flagging once
|
||||
|
||||
setIsFlagged(true);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_flag_nsfw",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleFlagNsfw}
|
||||
disabled={isFlagged}
|
||||
opacity={isFlagged ? 0.5 : 1}
|
||||
aria-label="Flag as NSFW"
|
||||
>
|
||||
<Flag
|
||||
size={32}
|
||||
color="white"
|
||||
fill={isFlagged ? "white" : "none"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type DownloadButtonProps = {
|
||||
skin: ClientSkin;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
|
||||
const handleDownload = async () => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_download",
|
||||
skinMd5: skin.md5,
|
||||
});
|
||||
|
||||
// Trigger download
|
||||
window.location.href = skin.downloadUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleDownload} aria-label="Download">
|
||||
<Download size={32} color="white" strokeWidth={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
97
packages/skin-database/app/(modern)/scroll/SkinPage.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
import { unstable_ViewTransition as ViewTransition } from "react";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import SkinActionIcons from "./SkinActionIcons";
|
||||
import WebampComponent from "./Webamp";
|
||||
|
||||
type Props = {
|
||||
skin: ClientSkin;
|
||||
index: number;
|
||||
sessionId: string;
|
||||
focused: boolean;
|
||||
};
|
||||
|
||||
export default function SkinPage({ skin, index, sessionId, focused }: Props) {
|
||||
const showWebamp = focused;
|
||||
return (
|
||||
<div
|
||||
key={skin.md5}
|
||||
skin-md5={skin.md5}
|
||||
skin-index={index}
|
||||
className="scroller"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
position: "relative",
|
||||
paddingTop: "2rem", // Space for top shadow
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<ViewTransition name={`skin-${skin.md5}`}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "275 / 348",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
{showWebamp && (
|
||||
<WebampComponent
|
||||
skinUrl={skin.skinUrl}
|
||||
closeModal={() => {}}
|
||||
loaded={() => {}}
|
||||
/>
|
||||
)}
|
||||
</ViewTransition>
|
||||
|
||||
<SkinActionIcons skin={skin} sessionId={sessionId} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
paddingLeft: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
fontSize: "0.9rem",
|
||||
paddingBottom: "0",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
color: "#ccc",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{skin.fileName}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
paddingTop: "0",
|
||||
color: "#999",
|
||||
fontFamily: 'monospace, "Courier New", Courier, monospace',
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{skin.readmeStart}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal file
216
packages/skin-database/app/(modern)/scroll/SkinScroller.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useLayoutEffect, useEffect } from "react";
|
||||
import SkinPage from "./SkinPage";
|
||||
import { logUserEvent } from "./Events";
|
||||
import { useScrollHint } from "./useScrollHint";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
|
||||
export type ClientSkin = {
|
||||
screenshotUrl: string;
|
||||
skinUrl: string;
|
||||
fileName: string;
|
||||
md5: string;
|
||||
readmeStart: string;
|
||||
downloadUrl: string;
|
||||
shareUrl: string;
|
||||
nsfw: boolean;
|
||||
likeCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialSkins: ClientSkin[];
|
||||
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function SkinScroller({
|
||||
initialSkins,
|
||||
getSkins,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
|
||||
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
|
||||
const [hasEverScrolled, setHasEverScrolled] = useState(false);
|
||||
|
||||
// Track if user has ever scrolled to another skin
|
||||
useEffect(() => {
|
||||
if (visibleSkinIndex > 0) {
|
||||
setHasEverScrolled(true);
|
||||
}
|
||||
}, [visibleSkinIndex]);
|
||||
|
||||
// Show scroll hint only if user has never scrolled to another skin
|
||||
useScrollHint({
|
||||
containerRef,
|
||||
enabled: visibleSkinIndex === 0 && !hasEverScrolled,
|
||||
onHintShown: () => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "scroll_hint_shown",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use IntersectionObserver for cross-browser compatibility (iOS doesn't support scrollsnapchange)
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
// When an element becomes mostly visible (> 50% intersecting)
|
||||
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
|
||||
const index = parseInt(
|
||||
entry.target.getAttribute("skin-index") || "0",
|
||||
10
|
||||
);
|
||||
setVisibleSkinIndex(index);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: containerRef,
|
||||
threshold: 0.5, // Trigger when 50% of the element is visible
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all skin page elements
|
||||
const skinElements = containerRef.querySelectorAll("[skin-index]");
|
||||
skinElements.forEach((element) => observer.observe(element));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [containerRef, skins.length]);
|
||||
|
||||
useEffect(() => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_start",
|
||||
});
|
||||
|
||||
function beforeUnload() {
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_end",
|
||||
reason: "before_unload",
|
||||
});
|
||||
}
|
||||
|
||||
addEventListener("beforeunload", beforeUnload);
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnload);
|
||||
logUserEvent(sessionId, {
|
||||
type: "session_end",
|
||||
reason: "unmount",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// We want the URL and title to update as you scroll, but
|
||||
// we can't trigger a NextJS navigation since that would remount the
|
||||
// component. So, here we replicate the metadata behavior of the route.
|
||||
const skinMd5 = skins[visibleSkinIndex].md5;
|
||||
const newUrl = `/scroll/skin/${skinMd5}`;
|
||||
window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_view_start",
|
||||
skinMd5,
|
||||
});
|
||||
const startTime = Date.now();
|
||||
return () => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logUserEvent(sessionId, {
|
||||
type: "skin_view_end",
|
||||
skinMd5: skins[visibleSkinIndex].md5,
|
||||
durationMs,
|
||||
});
|
||||
};
|
||||
}, [visibleSkinIndex, skins, fetching]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (fetching) {
|
||||
return;
|
||||
}
|
||||
if (visibleSkinIndex + 5 >= skins.length) {
|
||||
setFetching(true);
|
||||
console.log("Fetching more skins...");
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_start",
|
||||
offset: skins.length,
|
||||
});
|
||||
getSkins(sessionId, skins.length)
|
||||
.then((newSkins) => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_success",
|
||||
offset: skins.length,
|
||||
});
|
||||
setSkins([...skins, ...newSkins]);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logUserEvent(sessionId, {
|
||||
type: "skins_fetch_failure",
|
||||
offset: skins.length,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
setFetching(false);
|
||||
});
|
||||
}
|
||||
}, [visibleSkinIndex, skins, fetching]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
style={{
|
||||
maxWidth: MOBILE_MAX_WIDTH, // 9:16 aspect ratio for scroll, full width for grid
|
||||
margin: "0 auto",
|
||||
height: "100vh",
|
||||
// width: "100%",
|
||||
overflowY: "scroll",
|
||||
scrollSnapType: "y mandatory",
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{skins.map((skin, i) => {
|
||||
return (
|
||||
<SkinPage
|
||||
key={skin.md5}
|
||||
skin={skin}
|
||||
index={i}
|
||||
focused={i === visibleSkinIndex}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Top shadow overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "4rem",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(26, 26, 26, 0.8) 0%, rgba(26, 26, 26, 0.4) 50%, rgba(26, 26, 26, 0) 100%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 500,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal file
182
packages/skin-database/app/(modern)/scroll/StaticPage.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { ReactNode, CSSProperties } from "react";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
|
||||
type StaticPageProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function StaticPage({ children }: StaticPageProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#1a1a1a",
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
margin: "0 auto",
|
||||
padding: "2rem 1.5rem",
|
||||
color: "#fff",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled heading components
|
||||
export function Heading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
marginBottom: "1.5rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
export function Subheading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
marginTop: "2rem",
|
||||
marginBottom: "1rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled link component
|
||||
export function Link({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
style={{
|
||||
color: "#6b9eff",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled paragraph component
|
||||
export function Paragraph({ children }: { children: ReactNode }) {
|
||||
return <p style={{ marginBottom: "1rem" }}>{children}</p>;
|
||||
}
|
||||
|
||||
// Styled form components
|
||||
export function Label({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.5rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
style,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<input
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "4px",
|
||||
color: "#fff",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
style,
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderRadius: "4px",
|
||||
color: "#fff",
|
||||
fontFamily: "inherit",
|
||||
display: "block",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
style,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<button
|
||||
style={{
|
||||
padding: "0.75rem 2rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: "#6b9eff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
...style,
|
||||
}}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal file
208
packages/skin-database/app/(modern)/scroll/Webamp.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
SCREENSHOT_HEIGHT,
|
||||
SCREENSHOT_WIDTH,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
|
||||
type Props = {
|
||||
skinUrl: string;
|
||||
closeModal: () => void;
|
||||
loaded: () => void;
|
||||
};
|
||||
|
||||
export default function WebampComponent({
|
||||
skinUrl,
|
||||
closeModal,
|
||||
loaded,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const outerRef = useRef<HTMLDivElement | null>(null);
|
||||
// @ts-ignore
|
||||
const webampRef = useRef<import("webamp").default | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function loadWebamp() {
|
||||
// @ts-ignore
|
||||
const { default: Webamp } = await import("webamp");
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
const webamp = new Webamp({
|
||||
initialSkin: { url: skinUrl },
|
||||
initialTracks,
|
||||
enableHotkeys: true,
|
||||
zIndex: 1001,
|
||||
});
|
||||
|
||||
webampRef.current = webamp;
|
||||
cleanup = () => webamp.dispose();
|
||||
|
||||
webamp.onClose(closeModal);
|
||||
// ref.current!.style.opacity = "0";
|
||||
await webamp.renderInto(ref.current!);
|
||||
const { width } = outerRef.current!.getBoundingClientRect();
|
||||
const zoom = width / SCREENSHOT_WIDTH;
|
||||
document
|
||||
.getElementById("webamp")
|
||||
?.style.setProperty("zoom", String(zoom));
|
||||
ref.current!.style.opacity = "1";
|
||||
|
||||
if (!disposed) loaded();
|
||||
}
|
||||
|
||||
loadWebamp();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [skinUrl, closeModal, loaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
style={{
|
||||
top: 0,
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="webamp-container"
|
||||
style={{
|
||||
width: SCREENSHOT_WIDTH,
|
||||
height: SCREENSHOT_HEIGHT,
|
||||
position: "relative",
|
||||
opacity: 0,
|
||||
transition: "opacity 1s linear",
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const album = "netBloc Vol. 24: tiuqottigeloot";
|
||||
|
||||
const initialTracks = [
|
||||
{
|
||||
metaData: {
|
||||
artist: "DJ Mike Llama",
|
||||
title: "Llama Whippin' Intro",
|
||||
},
|
||||
url: "/llama.mp3",
|
||||
duration: 5.322286,
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
|
||||
duration: 322.612245,
|
||||
metaData: {
|
||||
title: "Heroines",
|
||||
artist: "Diablo Swing Orchestra",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
|
||||
duration: 190.093061,
|
||||
metaData: {
|
||||
title: "We Are Going To Eclecfunk Your Ass",
|
||||
artist: "Eclectek",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Auto-Pilot_-_03_-_Seventeen.mp3",
|
||||
duration: 214.622041,
|
||||
metaData: {
|
||||
title: "Seventeen",
|
||||
artist: "Auto-Pilot",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Muha_-_04_-_Microphone.mp3",
|
||||
duration: 181.838367,
|
||||
metaData: {
|
||||
title: "Microphone",
|
||||
artist: "Muha",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Just_Plain_Ant_-_05_-_Stumble.mp3",
|
||||
duration: 86.047347,
|
||||
metaData: {
|
||||
title: "Stumble",
|
||||
artist: "Just Plain Ant",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Sleaze_-_06_-_God_Damn.mp3",
|
||||
duration: 226.795102,
|
||||
metaData: {
|
||||
title: "God Damn",
|
||||
artist: "Sleaze",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Juanitos_-_07_-_Hola_Hola_Bossa_Nova.mp3",
|
||||
duration: 207.072653,
|
||||
metaData: {
|
||||
title: "Hola Hola Bossa Nova",
|
||||
artist: "Juanitos",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Entertainment_for_the_Braindead_-_08_-_Resolutions_Chris_Summer_Remix.mp3",
|
||||
duration: 314.331429,
|
||||
metaData: {
|
||||
title: "Resolutions (Chris Summer Remix)",
|
||||
artist: "Entertainment for the Braindead",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Nobara_Hayakawa_-_09_-_Trail.mp3",
|
||||
duration: 204.042449,
|
||||
metaData: {
|
||||
title: "Trail",
|
||||
artist: "Nobara Hayakawa",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Paper_Navy_-_10_-_Tongue_Tied.mp3",
|
||||
duration: 201.116735,
|
||||
metaData: {
|
||||
title: "Tongue Tied",
|
||||
artist: "Paper Navy",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/60_Tigres_-_11_-_Garage.mp3",
|
||||
duration: 245.394286,
|
||||
metaData: {
|
||||
title: "Garage",
|
||||
artist: "60 Tigres",
|
||||
album,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/CM_aka_Creative_-_12_-_The_Cycle_Featuring_Mista_Mista.mp3",
|
||||
duration: 221.44,
|
||||
metaData: {
|
||||
title: "The Cycle (Featuring Mista Mista)",
|
||||
artist: "CM aka Creative",
|
||||
album,
|
||||
},
|
||||
},
|
||||
];
|
||||
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal file
67
packages/skin-database/app/(modern)/scroll/about/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import StaticPage, {
|
||||
Heading,
|
||||
Subheading,
|
||||
Link,
|
||||
Paragraph,
|
||||
} from "../StaticPage";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>About</Heading>
|
||||
<Paragraph>
|
||||
The Winamp Skin Museum is an attempt to build a <i>fast</i>,{" "}
|
||||
<i>searchable</i>, and <i>shareable</i>, interface for the collection of
|
||||
Winamp Skins amassed on the{" "}
|
||||
<Link
|
||||
href="https://archive.org/details/winampskins"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Internet Archive
|
||||
</Link>
|
||||
.
|
||||
</Paragraph>
|
||||
<Subheading>Features:</Subheading>
|
||||
<ul style={{ marginBottom: "1.5rem", paddingLeft: "1.5rem" }}>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Infinite scroll</strong> preview images
|
||||
</li>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Experience</strong> skins with integrated{" "}
|
||||
<Link
|
||||
href="https://webamp.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Webamp
|
||||
</Link>
|
||||
</li>
|
||||
<li style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Fast search</strong> of indexed readme.txt texts
|
||||
</li>
|
||||
</ul>
|
||||
<Paragraph>
|
||||
Made by <Link href="https://jordaneldredge.com">Jordan Eldredge</Link>
|
||||
</Paragraph>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
margin: "2rem 0",
|
||||
}}
|
||||
/>
|
||||
<Paragraph>
|
||||
Want Winamp on your Windows PC, but with supported updates & new
|
||||
features?{" "}
|
||||
<Link
|
||||
href="https://getwacup.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Try WACUP
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { algoliasearch } from "algoliasearch";
|
||||
|
||||
// Using the legacy hardcoded credentials for client-side search
|
||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||
|
||||
export async function searchSkins(query: string) {
|
||||
const result = await client.searchSingleIndex({
|
||||
indexName: "Skins",
|
||||
searchParams: {
|
||||
query,
|
||||
attributesToRetrieve: ["objectID", "fileName", "nsfw"],
|
||||
attributesToHighlight: [],
|
||||
hitsPerPage: 1000,
|
||||
typoTolerance: "min",
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal file
117
packages/skin-database/app/(modern)/scroll/feedback/page.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import StaticPage, {
|
||||
Heading,
|
||||
Paragraph,
|
||||
Label,
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
} from "../StaticPage";
|
||||
|
||||
async function sendFeedback(variables: {
|
||||
message: string;
|
||||
email: string;
|
||||
url: string;
|
||||
}) {
|
||||
const mutation = `
|
||||
mutation GiveFeedback($message: String!, $email: String, $url: String) {
|
||||
send_feedback(message: $message, email: $email, url: $url)
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch("/api/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send feedback");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const pathname = usePathname();
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (message.trim().length === 0) {
|
||||
alert("Please add a message before sending.");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
message,
|
||||
email,
|
||||
url: "https://skins.webamp.org" + pathname,
|
||||
};
|
||||
setSending(true);
|
||||
try {
|
||||
await sendFeedback(body);
|
||||
setSent(true);
|
||||
} catch (_) {
|
||||
alert("Failed to send feedback. Please try again.");
|
||||
setSending(false);
|
||||
}
|
||||
}, [message, email, pathname]);
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>Sent!</Heading>
|
||||
<Paragraph>
|
||||
Thanks for your feedback. I appreciate you taking the time to share
|
||||
your thoughts.
|
||||
</Paragraph>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPage>
|
||||
<Heading>Feedback</Heading>
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
Let me know what you think about the Winamp Skin Museum. Bug reports,
|
||||
feature suggestions, personal anecdotes, or criticism are all welcome.
|
||||
</p>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<Label>Message</Label>
|
||||
<Textarea
|
||||
disabled={sending}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
style={{ minHeight: 150 }}
|
||||
placeholder="Your thoughts here..."
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<Label>Email (optional)</Label>
|
||||
<Input
|
||||
disabled={sending}
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button onClick={send} disabled={sending}>
|
||||
{sending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</StaticPage>
|
||||
);
|
||||
}
|
||||
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal file
48
packages/skin-database/app/(modern)/scroll/getClientSkins.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import UserContext from "../../../data/UserContext";
|
||||
import SessionModel from "../../../data/SessionModel";
|
||||
import { ClientSkin } from "./SkinScroller";
|
||||
import { getScrollPage } from "../../../data/skins";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
|
||||
"use server";
|
||||
const ctx = new UserContext();
|
||||
|
||||
const page = await getScrollPage(sessionId);
|
||||
|
||||
return await Promise.all(
|
||||
page.map(async (item) => {
|
||||
return getSkinForSession(ctx, sessionId, item.md5);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSkinForSession(
|
||||
ctx: UserContext,
|
||||
sessionId: string,
|
||||
md5: string
|
||||
): Promise<ClientSkin> {
|
||||
const model = await SkinModel.fromMd5Assert(ctx, md5);
|
||||
const readmeText = await model.getReadme();
|
||||
const fileName = await model.getFileName();
|
||||
const tweet = await model.getTweet();
|
||||
const likeCount = tweet ? tweet.getLikes() : 0;
|
||||
|
||||
SessionModel.addSkin(sessionId, md5);
|
||||
|
||||
return {
|
||||
screenshotUrl: model.getScreenshotUrl(),
|
||||
skinUrl: model.getSkinUrl(),
|
||||
md5,
|
||||
// TODO: Normalize to .wsz
|
||||
fileName: fileName,
|
||||
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
|
||||
downloadUrl: model.getSkinUrl(),
|
||||
shareUrl: `https://skins.webamp.org/scroll/skin/${md5}`,
|
||||
nsfw: await model.getIsNsfw(),
|
||||
likeCount: likeCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use server";
|
||||
|
||||
import { getMuseumPage, getScreenshotUrl } from "../../../data/skins";
|
||||
|
||||
export type GridSkin = {
|
||||
md5: string;
|
||||
screenshotUrl: string;
|
||||
fileName: string;
|
||||
nsfw: boolean;
|
||||
};
|
||||
|
||||
export async function getMuseumPageSkins(
|
||||
offset: number,
|
||||
limit: number
|
||||
): Promise<GridSkin[]> {
|
||||
const page = await getMuseumPage({ offset, first: limit });
|
||||
|
||||
const skins = page.map((item) => ({
|
||||
md5: item.md5,
|
||||
screenshotUrl: getScreenshotUrl(item.md5),
|
||||
fileName: item.fileName,
|
||||
nsfw: item.nsfw,
|
||||
}));
|
||||
|
||||
return skins;
|
||||
}
|
||||
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
26
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
import { unstable_ViewTransition as ViewTransition, ReactNode } from "react";
|
||||
import BottomMenuBar from "./BottomMenuBar";
|
||||
import "./scroll.css";
|
||||
|
||||
type LayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ViewTransition>
|
||||
<BottomMenuBar />
|
||||
</ViewTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal file
12
packages/skin-database/app/(modern)/scroll/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import Grid from "./Grid";
|
||||
import { getMuseumPageSkins } from "./getMuseumPageSkins";
|
||||
import * as Skins from "../../..//data/skins";
|
||||
|
||||
export default async function SkinTable() {
|
||||
const [initialSkins, skinCount] = await Promise.all([
|
||||
getMuseumPageSkins(0, 50),
|
||||
Skins.getClassicSkinCount(),
|
||||
]);
|
||||
return <Grid initialSkins={initialSkins} initialTotal={skinCount} />;
|
||||
}
|
||||
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal file
22
packages/skin-database/app/(modern)/scroll/scroll.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
body {
|
||||
margin: 0; /* Remove default margin */
|
||||
height: 100vh; /* Set body height to viewport height */
|
||||
background-color: #1a1a1a; /* Dark charcoal instead of pure black */
|
||||
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar,
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
.scroller,
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Metadata } from "next";
|
||||
import SessionModel from "../../../../../data/SessionModel";
|
||||
import UserContext from "../../../../../data/UserContext";
|
||||
import { getClientSkins, getSkinForSession } from "../../getClientSkins";
|
||||
import SkinScroller from "../../SkinScroller";
|
||||
import { generateSkinPageMetadata } from "../../../../(legacy)/skin/[hash]/skinMetadata";
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { md5 } = await params;
|
||||
return generateSkinPageMetadata(md5);
|
||||
}
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Skin({ params }) {
|
||||
const { md5 } = await params;
|
||||
|
||||
// Create the session in the database
|
||||
const sessionId = await SessionModel.create();
|
||||
|
||||
const ctx = new UserContext();
|
||||
const linked = await getSkinForSession(ctx, sessionId, md5);
|
||||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={[linked, ...initialSkins]}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal file
24
packages/skin-database/app/(modern)/scroll/skin/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import SessionModel from "../../../../data/SessionModel";
|
||||
import { getClientSkins } from "../getClientSkins";
|
||||
import SkinScroller from "../SkinScroller";
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* A tik-tok style scroll page where we display one skin at a time in full screen
|
||||
*/
|
||||
export default async function ScrollPage() {
|
||||
// Create the session in the database
|
||||
const sessionId = await SessionModel.create();
|
||||
|
||||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={initialSkins}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal file
99
packages/skin-database/app/(modern)/scroll/useScrollHint.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
type UseScrollHintOptions = {
|
||||
containerRef: HTMLDivElement | null;
|
||||
enabled: boolean;
|
||||
delayMs?: number;
|
||||
scrollAmount?: number;
|
||||
animationDuration?: number;
|
||||
onHintShown?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that provides a gentle scroll hint animation to encourage user interaction.
|
||||
* After a delay, if the user hasn't scrolled, it will scroll down slightly and bounce back.
|
||||
*/
|
||||
export function useScrollHint({
|
||||
containerRef,
|
||||
enabled,
|
||||
delayMs = 2000,
|
||||
scrollAmount = 80,
|
||||
animationDuration = 1000,
|
||||
onHintShown,
|
||||
}: UseScrollHintOptions) {
|
||||
useEffect(() => {
|
||||
if (containerRef == null || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hintTimer = setTimeout(() => {
|
||||
if (!enabled || containerRef.scrollTop !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startScrollTop = containerRef.scrollTop;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Temporarily disable scroll snap for smooth animation
|
||||
const originalScrollSnapType = containerRef.style.scrollSnapType;
|
||||
containerRef.style.scrollSnapType = "none";
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
// Bouncy easing function - overshoots and bounces back
|
||||
const easeOutBounce = (t: number) => {
|
||||
const n1 = 7.5625;
|
||||
const d1 = 2.75;
|
||||
if (t < 1 / d1) {
|
||||
return n1 * t * t;
|
||||
} else if (t < 2 / d1) {
|
||||
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
||||
} else if (t < 2.5 / d1) {
|
||||
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
||||
} else {
|
||||
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a bounce effect: scroll down quickly, then bounce back
|
||||
let offset;
|
||||
if (progress < 0.4) {
|
||||
// First 40%: scroll down quickly
|
||||
const t = progress / 0.4;
|
||||
offset = scrollAmount * t * t; // Quadratic ease-in
|
||||
} else {
|
||||
// Last 60%: bounce back with overshoot
|
||||
const t = (progress - 0.4) / 0.6;
|
||||
offset = scrollAmount * (1 - easeOutBounce(t));
|
||||
}
|
||||
|
||||
containerRef.scrollTop = startScrollTop + offset;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Ensure we end exactly where we started
|
||||
containerRef.scrollTop = startScrollTop;
|
||||
// Re-enable scroll snap
|
||||
containerRef.style.scrollSnapType = originalScrollSnapType;
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
onHintShown?.();
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(hintTimer);
|
||||
};
|
||||
}, [
|
||||
containerRef,
|
||||
enabled,
|
||||
delayMs,
|
||||
scrollAmount,
|
||||
animationDuration,
|
||||
onHintShown,
|
||||
]);
|
||||
}
|
||||
|
|
@ -111,8 +111,8 @@ function SkinTableUnbound({
|
|||
}
|
||||
return skin ? skin.hash : `unfectched-index-${requestToken}`;
|
||||
}
|
||||
const gridRef = React.useRef();
|
||||
const itemRef = React.useRef();
|
||||
const gridRef = React.useRef<any>(null);
|
||||
const itemRef = React.useRef<number>(0);
|
||||
React.useLayoutEffect(() => {
|
||||
if (gridRef.current == null) {
|
||||
return;
|
||||
|
|
|
|||
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
483
packages/skin-database/app/bulk-download/page.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { fetchGraphql, gql } from "../../legacy-client/src/utils";
|
||||
|
||||
interface BulkDownloadSkin {
|
||||
md5: string;
|
||||
filename: string;
|
||||
download_url: string;
|
||||
__typename: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
totalSkins: number;
|
||||
completedSkins: number;
|
||||
failedSkins: number;
|
||||
estimatedSizeBytes: string;
|
||||
activeDownloads: Array<{
|
||||
filename: string;
|
||||
md5: string;
|
||||
status: "downloading" | "failed";
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DirectoryHandle {
|
||||
name: string;
|
||||
getDirectoryHandle: (
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
) => Promise<DirectoryHandle>;
|
||||
getFileHandle: (
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
) => Promise<FileSystemFileHandle>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
showDirectoryPicker?: () => Promise<DirectoryHandle>;
|
||||
}
|
||||
}
|
||||
|
||||
const BULK_DOWNLOAD_QUERY = gql`
|
||||
query BulkDownload($offset: Int!, $first: Int!) {
|
||||
bulkDownload(offset: $offset, first: $first) {
|
||||
totalCount
|
||||
estimatedSizeBytes
|
||||
nodes {
|
||||
__typename
|
||||
md5
|
||||
filename(normalize_extension: true, include_museum_id: true)
|
||||
download_url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 6;
|
||||
const CHUNK_SIZE = 1000;
|
||||
|
||||
export default function BulkDownloadPage() {
|
||||
const [directoryHandle, setDirectoryHandle] =
|
||||
useState<DirectoryHandle | null>(null);
|
||||
const [progress, setProgress] = useState<DownloadProgress>({
|
||||
totalSkins: 0,
|
||||
completedSkins: 0,
|
||||
failedSkins: 0,
|
||||
estimatedSizeBytes: "0",
|
||||
activeDownloads: [],
|
||||
});
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSupported] = useState(
|
||||
typeof window !== "undefined" && "showDirectoryPicker" in window
|
||||
);
|
||||
const abortController = useRef<AbortController | null>(null);
|
||||
|
||||
const downloadSkin = useCallback(
|
||||
async (
|
||||
skin: BulkDownloadSkin,
|
||||
directoryHandle: DirectoryHandle,
|
||||
signal: AbortSignal
|
||||
): Promise<void> => {
|
||||
const { filename, download_url, md5 } = skin;
|
||||
|
||||
// Get the target directory and file path
|
||||
const targetDirectory = await getDirectoryForSkin(
|
||||
filename,
|
||||
directoryHandle
|
||||
);
|
||||
// Check if file already exists
|
||||
try {
|
||||
await targetDirectory.getFileHandle(filename);
|
||||
// File exists, skip download
|
||||
console.log(`Skipping ${filename} - already exists`);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedSkins: prev.completedSkins + 1,
|
||||
}));
|
||||
return;
|
||||
} catch (_) {
|
||||
// File doesn't exist, continue with download
|
||||
}
|
||||
|
||||
// Add to active downloads
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
activeDownloads: [
|
||||
...prev.activeDownloads,
|
||||
{
|
||||
filename,
|
||||
md5,
|
||||
status: "downloading" as const,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch(download_url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// We don't need individual progress tracking anymore
|
||||
// const contentLength = parseInt(response.headers.get("content-length") || "0", 10);
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
// Use the targetDirectory and finalFilename we calculated earlier
|
||||
const fileHandle = await targetDirectory.getFileHandle(filename, {
|
||||
create: true,
|
||||
});
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
// Track total bytes for this file (not needed for individual progress)
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
await writable.write(value);
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
|
||||
// Mark as completed and immediately remove from active downloads
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedSkins: prev.completedSkins + 1,
|
||||
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
|
||||
}));
|
||||
} catch (writeError) {
|
||||
await writable.abort("Failed to write file");
|
||||
throw writeError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
console.log(`Download aborted: ${filename}`);
|
||||
throw error; // Re-throw abort errors
|
||||
}
|
||||
|
||||
// Mark as failed and schedule removal
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
failedSkins: prev.failedSkins + 1,
|
||||
activeDownloads: prev.activeDownloads.map((d) =>
|
||||
d.md5 === md5
|
||||
? {
|
||||
...d,
|
||||
status: "failed" as const,
|
||||
error: error.message,
|
||||
}
|
||||
: d
|
||||
),
|
||||
}));
|
||||
|
||||
// Remove failed download after 3 seconds
|
||||
setTimeout(() => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
|
||||
}));
|
||||
}, 3000);
|
||||
|
||||
console.error(`Failed to download ${filename}:`, error);
|
||||
}
|
||||
},
|
||||
[getDirectoryForSkin]
|
||||
);
|
||||
|
||||
// Load initial metadata when component mounts
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
totalSkins: totalCount,
|
||||
estimatedSizeBytes,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load initial data:", error);
|
||||
setError("Failed to load skin count information");
|
||||
}
|
||||
}
|
||||
|
||||
loadInitialData();
|
||||
}, [fetchSkins]);
|
||||
|
||||
const selectDirectoryAndStart = useCallback(async () => {
|
||||
// First, select directory if not already selected
|
||||
if (!directoryHandle) {
|
||||
if (!window.showDirectoryPicker) {
|
||||
setError(
|
||||
"File System Access API is not supported in this browser. Please use Chrome or Edge."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
setDirectoryHandle(handle);
|
||||
setError(null);
|
||||
|
||||
// Now start the download with the new directory
|
||||
await startDownloadWithDirectory(handle as FileSystemDirectoryHandle);
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setError(`Failed to select directory: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Directory already selected, just start download
|
||||
await startDownloadWithDirectory(
|
||||
directoryHandle as FileSystemDirectoryHandle
|
||||
);
|
||||
}
|
||||
}, [directoryHandle]);
|
||||
|
||||
const startDownloadWithDirectory = useCallback(
|
||||
async (handle: FileSystemDirectoryHandle) => {
|
||||
setIsDownloading(true);
|
||||
setError(null);
|
||||
// setStartTime(Date.now());
|
||||
abortController.current = new AbortController();
|
||||
|
||||
try {
|
||||
// Get initial metadata
|
||||
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
|
||||
|
||||
setProgress({
|
||||
totalSkins: totalCount,
|
||||
completedSkins: 0,
|
||||
failedSkins: 0,
|
||||
estimatedSizeBytes,
|
||||
activeDownloads: [],
|
||||
});
|
||||
|
||||
let offset = 0;
|
||||
const activePromises = new Set<Promise<void>>();
|
||||
|
||||
while (offset < totalCount && !abortController.current.signal.aborted) {
|
||||
console.log(`Fetching batch: offset=${offset}, chunk=${CHUNK_SIZE}`);
|
||||
|
||||
try {
|
||||
const { skins } = await fetchSkins(offset, CHUNK_SIZE);
|
||||
console.log(`Retrieved ${skins.length} skins in this batch`);
|
||||
|
||||
if (skins.length === 0) {
|
||||
console.log("No more skins to fetch, breaking");
|
||||
break;
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
// eslint-disable-next-line max-depth
|
||||
if (abortController.current.signal.aborted) break;
|
||||
|
||||
await waitForAvailableSlot(
|
||||
activePromises,
|
||||
abortController.current.signal
|
||||
);
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (abortController.current.signal.aborted) break;
|
||||
|
||||
const downloadPromise = downloadSkin(
|
||||
skin,
|
||||
handle,
|
||||
abortController.current.signal
|
||||
).finally(() => {
|
||||
activePromises.delete(downloadPromise);
|
||||
});
|
||||
|
||||
activePromises.add(downloadPromise);
|
||||
}
|
||||
|
||||
offset += skins.length;
|
||||
console.log(`Completed batch, new offset: ${offset}/${totalCount}`);
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch batch at offset ${offset}:`, error);
|
||||
setError(`Failed to fetch skins: ${error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all remaining downloads to complete
|
||||
await Promise.allSettled(activePromises);
|
||||
console.log("All downloads completed!");
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
setError(`Download failed: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[fetchSkins, downloadSkin]
|
||||
);
|
||||
|
||||
const stopDownload = useCallback(() => {
|
||||
if (abortController.current) {
|
||||
abortController.current.abort("User Canceled");
|
||||
}
|
||||
setIsDownloading(false);
|
||||
// setStartTime(null);
|
||||
}, []);
|
||||
|
||||
const progressPercent =
|
||||
progress.totalSkins > 0
|
||||
? ((progress.completedSkins + progress.failedSkins) /
|
||||
progress.totalSkins) *
|
||||
100
|
||||
: 0;
|
||||
|
||||
if (!isSupported) {
|
||||
return <h1>Your browser does not support filesystem access.</h1>;
|
||||
}
|
||||
|
||||
const gb = Math.round(
|
||||
parseInt(progress.estimatedSizeBytes || "0", 10) / (1024 * 1024 * 1024)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<h1>Bulk Download All Skins</h1>
|
||||
<p>Download the entire Winamp Skin Museum collection.</p>
|
||||
<ul>
|
||||
<li>
|
||||
Will download {progress.totalSkins.toLocaleString()} files (~
|
||||
{gb}
|
||||
GB) into the selected directory
|
||||
</li>
|
||||
<li>
|
||||
Files will be organized into directories (aa-zz, 0-9) based on
|
||||
filename prefix
|
||||
</li>
|
||||
<li>
|
||||
Supports resuming from previously interrupted bulk download
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Controls */}
|
||||
<div>
|
||||
{isDownloading ? (
|
||||
<button onClick={stopDownload}>Stop Download</button>
|
||||
) : (
|
||||
<button onClick={selectDirectoryAndStart}>
|
||||
{directoryHandle
|
||||
? "Start Download"
|
||||
: "Select Directory & Start Download"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(isDownloading || progress.completedSkins > 0) && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span>
|
||||
Downloaded{" "}
|
||||
{(
|
||||
progress.completedSkins + progress.failedSkins
|
||||
).toLocaleString()}{" "}
|
||||
of {progress.totalSkins.toLocaleString()} skins
|
||||
</span>
|
||||
<span>{Math.round(progressPercent)}% complete</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "black",
|
||||
transition: "all 300ms",
|
||||
height: "18px",
|
||||
width: `${progressPercent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getDirectoryForSkin(
|
||||
filename: string,
|
||||
rootHandle: DirectoryHandle
|
||||
) {
|
||||
// Create directory based on first two characters of filename (case insensitive)
|
||||
const firstChar = filename.charAt(0).toLowerCase();
|
||||
const secondChar =
|
||||
filename.length > 1 ? filename.charAt(1).toLowerCase() : "";
|
||||
|
||||
let dirName: string;
|
||||
if (/[a-z]/.test(firstChar)) {
|
||||
// For letters, use two-character prefix if second char is alphanumeric
|
||||
if (/[a-z0-9]/.test(secondChar)) {
|
||||
dirName = firstChar + secondChar;
|
||||
} else {
|
||||
// Fallback to single letter + 'x' for special characters
|
||||
dirName = firstChar + "x";
|
||||
}
|
||||
} else {
|
||||
// For numbers/symbols, use "0-9"
|
||||
dirName = "0-9";
|
||||
}
|
||||
|
||||
try {
|
||||
return await rootHandle.getDirectoryHandle(dirName, { create: true });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to create directory ${dirName}, using root:`, err);
|
||||
return rootHandle;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSkins(
|
||||
offset: number,
|
||||
first: number
|
||||
): Promise<{
|
||||
skins: BulkDownloadSkin[];
|
||||
totalCount: number;
|
||||
estimatedSizeBytes: string;
|
||||
}> {
|
||||
const { bulkDownload } = await fetchGraphql(BULK_DOWNLOAD_QUERY, {
|
||||
offset,
|
||||
first,
|
||||
});
|
||||
return {
|
||||
skins: bulkDownload.nodes,
|
||||
totalCount: bulkDownload.totalCount,
|
||||
estimatedSizeBytes: bulkDownload.estimatedSizeBytes,
|
||||
};
|
||||
}
|
||||
// Helper function to wait for an available download slot
|
||||
async function waitForAvailableSlot(
|
||||
activePromises: Set<Promise<void>>,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
while (activePromises.size >= MAX_CONCURRENT_DOWNLOADS && !signal.aborted) {
|
||||
await Promise.race(activePromises);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ const { handleRequest } = createYogaInstance({
|
|||
return new UserContext();
|
||||
},
|
||||
logger: {
|
||||
log: (message: string, context: Record<string, any>) => {
|
||||
console.log(message, context);
|
||||
log: (_message: string, _context: Record<string, any>) => {
|
||||
// console.log(message, context);
|
||||
},
|
||||
logError: (message: string, context: Record<string, any>) => {
|
||||
console.error(message, context);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import App from "../App";
|
||||
import App from "./App";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
const DESCRIPTION =
|
||||
|
|
@ -35,11 +35,14 @@ import { setHashesForSkin } from "./skinHash";
|
|||
import * as S3 from "./s3";
|
||||
import { generateDescription } from "./services/openAi";
|
||||
import KeyValue from "./data/KeyValue";
|
||||
import { postToBluesky } from "./tasks/bluesky";
|
||||
import { computeSkinRankings } from "./tasks/computeScrollRanking";
|
||||
|
||||
async function withHandler(
|
||||
cb: (handler: DiscordEventHandler) => Promise<void>
|
||||
) {
|
||||
const handler = new DiscordEventHandler();
|
||||
await handler._clientPromise; // Ensure client is initialized
|
||||
try {
|
||||
await cb(handler);
|
||||
} finally {
|
||||
|
|
@ -81,21 +84,30 @@ program
|
|||
.argument("[md5]", "md5 of the skin to share")
|
||||
.option("-t, --twitter", "Share on Twitter")
|
||||
.option("-i, --instagram", "Share on Instagram")
|
||||
.option("-b, --bluesky", "Share on Bluesky")
|
||||
.option("-m, --mastodon", "Share on Mastodon")
|
||||
.action(async (md5, { twitter, instagram, mastodon }) => {
|
||||
if (!twitter && !instagram && !mastodon) {
|
||||
throw new Error("Expected at least one of --twitter or --instagram");
|
||||
}
|
||||
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
|
||||
await withDiscordClient(async (client) => {
|
||||
if (twitter) {
|
||||
await tweet(client, md5);
|
||||
return;
|
||||
}
|
||||
if (instagram) {
|
||||
await insta(client, md5);
|
||||
return;
|
||||
}
|
||||
if (mastodon) {
|
||||
await postToMastodon(client, md5);
|
||||
return;
|
||||
}
|
||||
if (bluesky) {
|
||||
await postToBluesky(client, md5);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -161,7 +173,7 @@ program
|
|||
console.log("====================================");
|
||||
}
|
||||
if (purge) {
|
||||
// cat purge | xargs -I {} yarn cli skin --purge {}
|
||||
// cat purge | xargs -I {} pnpm cli skin --purge {}
|
||||
await Skins.deleteSkin(md5);
|
||||
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
|
||||
const purged = new Set(purgedArr);
|
||||
|
|
@ -298,6 +310,14 @@ program
|
|||
console.table([await Skins.getStats()]);
|
||||
});
|
||||
|
||||
program
|
||||
.command("compute-scroll-ranking")
|
||||
.description("Analyze user event data and compute skin ranking scores.")
|
||||
.action(async () => {
|
||||
const rankings = await computeSkinRankings();
|
||||
console.log(JSON.stringify(rankings, null, 2));
|
||||
});
|
||||
|
||||
program
|
||||
.command("process-uploads")
|
||||
.description("Process any unprocessed user uploads.")
|
||||
|
|
@ -438,7 +458,7 @@ program
|
|||
);
|
||||
const md5s = rows.map((row) => row.md5);
|
||||
console.log(md5s.length);
|
||||
console.log(await Skins.updateSearchIndexs(ctx, md5s));
|
||||
console.log(await Skins.updateSearchIndexes(ctx, md5s));
|
||||
}
|
||||
if (refreshContentHash) {
|
||||
const ctx = new UserContext();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
|
|||
// Used for session encryption
|
||||
export const SECRET = env("SECRET");
|
||||
export const NODE_ENV = env("NODE_ENV") || "production";
|
||||
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
|
||||
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
|
||||
|
||||
function env(key: string): string {
|
||||
const value = process.env[key];
|
||||
|
|
|
|||
29
packages/skin-database/data/SessionModel.ts
Normal file
29
packages/skin-database/data/SessionModel.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { knex } from "../db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default class SessionModel {
|
||||
static async create(): Promise<string> {
|
||||
const sessionId = randomUUID();
|
||||
const startTime = Date.now();
|
||||
await knex("session").insert({
|
||||
id: sessionId,
|
||||
start_time: startTime,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
|
||||
await knex("session_skin").insert({
|
||||
session_id: sessionId,
|
||||
skin_md5: skinMd5,
|
||||
});
|
||||
}
|
||||
|
||||
static async getIncludedSkinCount(sessionId: string): Promise<number> {
|
||||
const result = await knex("session_skin")
|
||||
.where({ session_id: sessionId })
|
||||
.count("* as count")
|
||||
.first();
|
||||
return result ? (result.count as number) : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -158,7 +158,7 @@ export default class SkinModel {
|
|||
return "UNREVIEWED";
|
||||
}
|
||||
|
||||
async getFileName(): Promise<string> {
|
||||
async getFileName(normalizeExtension?: boolean): Promise<string> {
|
||||
const files = await this.getFiles();
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
|
||||
|
|
@ -167,6 +167,9 @@ export default class SkinModel {
|
|||
if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) {
|
||||
throw new Error("Expected filename to end with zip, wsz or wal.");
|
||||
}
|
||||
if (normalizeExtension) {
|
||||
return path.parse(filename).name + ".wsz";
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
|
|
@ -193,9 +196,6 @@ export default class SkinModel {
|
|||
const filename = file.getFileName();
|
||||
const isReadme = IS_README.test(filename);
|
||||
const isNotReadme = IS_NOT_README.test(filename);
|
||||
|
||||
console.log({ filename, isReadme, isNotReadme, md5: file.getFileMd5() });
|
||||
|
||||
return isReadme && !isNotReadme;
|
||||
});
|
||||
|
||||
|
|
@ -232,8 +232,8 @@ export default class SkinModel {
|
|||
}
|
||||
}
|
||||
|
||||
getAverageColor(): string {
|
||||
return this.row.average_color;
|
||||
getAverageColor(): string | null {
|
||||
return this.row.average_color ?? null;
|
||||
}
|
||||
|
||||
getBuffer = mem(async (): Promise<Buffer> => {
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@ export async function markAsPostedToMastodon(
|
|||
);
|
||||
}
|
||||
|
||||
export async function markAsPostedToBlueSky(
|
||||
md5: string,
|
||||
postId: string,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
await knex("bluesky_posts").insert(
|
||||
{ skin_md5: md5, post_id: postId, url },
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Also path actor
|
||||
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
|
||||
const index = { objectID: md5, nsfw: true };
|
||||
|
|
@ -243,7 +254,7 @@ async function getSearchIndexes(
|
|||
);
|
||||
}
|
||||
|
||||
export async function updateSearchIndexs(
|
||||
export async function updateSearchIndexes(
|
||||
ctx: UserContext,
|
||||
md5s: string[]
|
||||
): Promise<any> {
|
||||
|
|
@ -265,7 +276,7 @@ export async function updateSearchIndex(
|
|||
ctx: UserContext,
|
||||
md5: string
|
||||
): Promise<any | null> {
|
||||
return updateSearchIndexs(ctx, [md5]);
|
||||
return updateSearchIndexes(ctx, [md5]);
|
||||
}
|
||||
|
||||
export async function hideSkin(md5: string): Promise<void> {
|
||||
|
|
@ -400,6 +411,7 @@ export async function getErroredUpload(): Promise<{
|
|||
.where("status", "ERRORED")
|
||||
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
|
||||
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
|
||||
.where("skin_md5", "!=", "a418fd00583006b6e79cf0b251c43771")
|
||||
.first(["skin_md5", "id", "filename"]);
|
||||
return found || null;
|
||||
}
|
||||
|
|
@ -550,6 +562,31 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
|
|||
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> {
|
||||
const rows = await knex("skins")
|
||||
.where({ skin_type: 1 })
|
||||
|
|
@ -682,6 +719,53 @@ export type MuseumPage = Array<{
|
|||
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({
|
||||
offset,
|
||||
first,
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
// To toggle between blue and green deployments:
|
||||
// sudo vim /etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf
|
||||
// Update port number
|
||||
// sudo systemctl reload apache2
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "skin-database-blue",
|
||||
script: "yarn",
|
||||
interpreter: "bash",
|
||||
args: "start",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 3001,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skin-database-green",
|
||||
script: "yarn",
|
||||
interpreter: "bash",
|
||||
args: "start",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 3002,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -31,3 +31,5 @@ process.env.INSTAGRAM_ACCESS_TOKEN = "<DUMMY>";
|
|||
process.env.INSTAGRAM_ACCOUNT_ID = "<DUMMY>";
|
||||
process.env.MASTODON_ACCESS_TOKEN = "<DUMMY>";
|
||||
process.env.SECRET = "<DUMMY>";
|
||||
process.env.BLUESKY_PASSWORD = "<DUMMY>";
|
||||
process.env.BLUESKY_USERNAME = "<DUMMY>";
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
"use client";
|
||||
import React, { useCallback } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { connect, useSelector } from "react-redux";
|
||||
import About from "./About";
|
||||
import Feedback from "./Feedback";
|
||||
import Header from "./Header";
|
||||
import Overlay from "./Overlay";
|
||||
import SkinTable from "./SkinTable";
|
||||
import FocusedSkin from "./FocusedSkin";
|
||||
import { useSelector } from "react-redux";
|
||||
import * as Selectors from "./redux/selectors";
|
||||
import * as Actions from "./redux/actionCreators";
|
||||
import { ABOUT_PAGE, REVIEW_PAGE } from "./constants";
|
||||
import {
|
||||
ABOUT_PAGE,
|
||||
REVIEW_PAGE,
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
} from "./constants";
|
||||
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
|
||||
import { SCREENSHOT_WIDTH, SKIN_RATIO } from "./constants";
|
||||
import UploadGrid from "./upload/UploadGrid";
|
||||
import Metadata from "./components/Metadata";
|
||||
import SkinReadme from "./SkinReadme";
|
||||
|
|
@ -78,6 +81,7 @@ function App(props) {
|
|||
windowWidth={windowWidthWithScrollabar}
|
||||
/>
|
||||
)}
|
||||
{/* eslint-disable-next-line no-nested-ternary -- legacy code */}
|
||||
{props.showFeedbackForm ? (
|
||||
<Overlay>
|
||||
<Feedback />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { getUrl } from "./redux/selectors";
|
||||
import * as Actions from "./redux/actionCreators";
|
||||
import { useActionCreator } from "./hooks";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { fetchGraphql, gql } from "./utils";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as Utils from "./utils";
|
||||
import { gql } from "./utils";
|
||||
import { algoliasearch } from "algoliasearch";
|
||||
|
||||
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
||||
|
|
@ -6,14 +7,15 @@ const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
|
|||
// Fallback search that uses SQLite. Useful for when we've exceeded the Algolia
|
||||
// search quota.
|
||||
export async function graphqlSearch(query) {
|
||||
const queryText = Utils.gql`
|
||||
query SearchQuery($query: String!) {
|
||||
search_classic_skins(query: $query, first: 500) {
|
||||
filename
|
||||
md5
|
||||
nsfw
|
||||
}
|
||||
}`;
|
||||
const queryText = gql`
|
||||
query SearchQuery($query: String!) {
|
||||
search_classic_skins(query: $query, first: 500) {
|
||||
filename(normalize_extension: true)
|
||||
md5
|
||||
nsfw
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await Utils.fetchGraphql(queryText, { query });
|
||||
const hits = data.search_classic_skins.map((skin) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React, { useLayoutEffect, useState } from "react";
|
|||
function DownloadText({ text, children, ...restProps }) {
|
||||
const [url, setUrl] = useState(null);
|
||||
useLayoutEffect(() => {
|
||||
var blob = new Blob([text], {
|
||||
let blob = new Blob([text], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export const SCREENSHOT_WIDTH = 275;
|
||||
export const SCREENSHOT_HEIGHT = 348;
|
||||
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 UPLOAD_PAGE = "UPLOAD_PAGE";
|
||||
export const REVIEW_PAGE = "REVIEW_PAGE";
|
||||
|
|
@ -32,9 +33,8 @@ export const SKIN_CDN = R2_CDN;
|
|||
// export const SCREENSHOT_CDN = "https://cdn.webampskins.org";
|
||||
// export const SKIN_CDN = "https://cdn.webampskins.org";
|
||||
|
||||
// Note: This is a Cloudflare proxy for api.webamp.org which
|
||||
// provides some additional caching.
|
||||
export const API_URL = "https://api.webampskins.org";
|
||||
// Sites have been unified, we can point to ourselves now
|
||||
export const API_URL = "https://skins.webamp.org";
|
||||
// export const API_URL = "https://dev.webamp.org";
|
||||
export const HEADING_HEIGHT = 46;
|
||||
export const CHUNK_SIZE = 300;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import SparkMD5 from "spark-md5";
|
||||
|
||||
export function hashFile(file) {
|
||||
var blobSlice =
|
||||
let blobSlice =
|
||||
File.prototype.slice ||
|
||||
File.prototype.mozSlice ||
|
||||
File.prototype.webkitSlice;
|
||||
|
|
@ -26,7 +26,7 @@ export function hashFile(file) {
|
|||
fileReader.onerror = reject;
|
||||
|
||||
function loadNext() {
|
||||
var start = currentChunk * chunkSize,
|
||||
let start = currentChunk * chunkSize,
|
||||
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
|
||||
|
||||
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ const unloadedSkinEpic = (actions, _states) =>
|
|||
count
|
||||
nodes {
|
||||
md5
|
||||
filename
|
||||
filename(normalize_extension: true)
|
||||
nsfw
|
||||
}
|
||||
}
|
||||
|
|
@ -462,7 +462,10 @@ const urlEpic = (actions, state) => {
|
|||
|
||||
const newUrl = proposedUrl.toString();
|
||||
|
||||
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
||||
// Avoid clobbering URL for beta site
|
||||
if (!window.location.href.includes("/scroll")) {
|
||||
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
|
||||
}
|
||||
}),
|
||||
ignoreElements()
|
||||
);
|
||||
|
|
@ -481,7 +484,7 @@ const skinDataEpic = (actions, state) => {
|
|||
const QUERY = gql`
|
||||
query IndividualSkin($md5: String!) {
|
||||
fetch_skin_by_md5(md5: $md5) {
|
||||
filename
|
||||
filename(normalize_extension: true)
|
||||
nsfw
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function Row({ name, loading, right, complete }) {
|
|||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
// eslint-disable-next-line no-nested-ternary -- legacy code
|
||||
width: loading ? `90%` : complete ? `100%` : `0%`,
|
||||
transitionProperty: "all",
|
||||
// TODO: Try to learn how long it really takes
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ export async function upload(fileObj) {
|
|||
console.warn(
|
||||
`Request to ${uploadUrl} returned 503, going to retry again in 5 seconds. ${retries} retries left...`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function museumUrlFromHash(hash) {
|
|||
}
|
||||
|
||||
export function getWindowSize() {
|
||||
var w = window,
|
||||
let w = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName("body")[0],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import * as Knex from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<any> {
|
||||
await knex.raw(
|
||||
`CREATE TABLE "bluesky_posts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
|
||||
"skin_md5" TEXT NOT NULL,
|
||||
post_id text NOT NULL UNIQUE,
|
||||
url text NOT NULL UNIQUE
|
||||
);`
|
||||
);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<any> {
|
||||
await knex.raw(`DROP TABLE "bluesky_posts"`);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as Knex from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.raw(`
|
||||
CREATE TABLE user_log_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
metadata TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_session_id ON user_log_events(session_id);
|
||||
CREATE INDEX idx_timestamp ON user_log_events(timestamp);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS user_log_events;
|
||||
`);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import * as Knex from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.raw(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
start_time INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE TABLE session_skin (
|
||||
session_id TEXT NOT NULL,
|
||||
skin_md5 TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES session(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX idx_session_skin_session_id ON session_skin(session_id);
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX idx_session_skin_skin_md5 ON session_skin(skin_md5);
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS session_skin;
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
DROP TABLE IF EXISTS session;
|
||||
`);
|
||||
}
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
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"],
|
||||
experimental: {
|
||||
viewTransition: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.17.2",
|
||||
"@next/third-parties": "^15.3.3",
|
||||
"@sentry/node": "^5.27.3",
|
||||
"@sentry/tracing": "^5.27.3",
|
||||
|
|
@ -24,9 +25,10 @@
|
|||
"jszip": "^3.10.1",
|
||||
"knex": "^0.21.1",
|
||||
"lru-cache": "^6.0.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"mastodon-api": "^1.3.0",
|
||||
"md5": "^2.2.1",
|
||||
"next": "^15.3.3",
|
||||
"next": "^15.3.6",
|
||||
"node-fetch": "^2.6.7",
|
||||
"openai": "^4.68.0",
|
||||
"polygon-clipping": "^0.15.3",
|
||||
|
|
@ -46,7 +48,8 @@
|
|||
"ts-node": "^10.5.0",
|
||||
"twit": "^2.2.11",
|
||||
"winston": "^3.2.1",
|
||||
"yargs": "^13.2.4"
|
||||
"yargs": "^13.2.4",
|
||||
"webamp": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
|
|
@ -73,9 +76,10 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/react": "^19.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
||||
"@typescript-eslint/parser": "^8.36.0",
|
||||
"grats": "^0.0.31",
|
||||
"grats": "0.0.0-main-e655d1ae",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"jest": {
|
||||
|
|
|
|||
304
packages/skin-database/scripts/deploy.ts
Normal file
304
packages/skin-database/scripts/deploy.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* Simple Blue/Green Deployment Script
|
||||
* This script handles deploying to the inactive instance and switching traffic
|
||||
*
|
||||
* Usage: npx tsx scripts/deploy.ts
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { readFileSync, copyFileSync } from "fs";
|
||||
import * as readline from "readline";
|
||||
|
||||
// ANSI color codes
|
||||
const colors = {
|
||||
red: "\x1b[0;31m",
|
||||
green: "\x1b[0;32m",
|
||||
blue: "\x1b[0;34m",
|
||||
cyan: "\x1b[0;36m",
|
||||
yellow: "\x1b[1;33m",
|
||||
bold: "\x1b[1m",
|
||||
reset: "\x1b[0m",
|
||||
} as const;
|
||||
|
||||
// Configuration
|
||||
const APACHE_CONFIG = "/etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf";
|
||||
const BLUE_PORT = 3001;
|
||||
const GREEN_PORT = 3002;
|
||||
|
||||
type Color = "blue" | "green";
|
||||
|
||||
interface DeploymentState {
|
||||
currentColor: Color;
|
||||
currentPort: number;
|
||||
newColor: Color;
|
||||
newPort: number;
|
||||
}
|
||||
|
||||
function log(message: string, color?: keyof typeof colors): void {
|
||||
if (color) {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
function logBlank(): void {
|
||||
console.log();
|
||||
}
|
||||
|
||||
function exec(command: string, description: string): void {
|
||||
try {
|
||||
execSync(command, { stdio: "inherit", shell: "/bin/bash" });
|
||||
} catch (error) {
|
||||
log(`✗ Failed to ${description}`, "red");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function execSilent(command: string): string {
|
||||
return execSync(command, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function detectCurrentDeployment(): DeploymentState {
|
||||
log("→ Detecting current active deployment...", "cyan");
|
||||
|
||||
const apacheConfig = readFileSync(APACHE_CONFIG, "utf8");
|
||||
const isBlueActive = apacheConfig.includes(`localhost:${BLUE_PORT}`);
|
||||
|
||||
if (isBlueActive) {
|
||||
log(
|
||||
` Current active: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
|
||||
);
|
||||
log(
|
||||
` Deploying to: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
|
||||
);
|
||||
logBlank();
|
||||
return {
|
||||
currentColor: "blue",
|
||||
currentPort: BLUE_PORT,
|
||||
newColor: "green",
|
||||
newPort: GREEN_PORT,
|
||||
};
|
||||
} else {
|
||||
log(
|
||||
` Current active: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
|
||||
);
|
||||
log(
|
||||
` Deploying to: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
|
||||
);
|
||||
logBlank();
|
||||
return {
|
||||
currentColor: "green",
|
||||
currentPort: GREEN_PORT,
|
||||
newColor: "blue",
|
||||
newPort: BLUE_PORT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForConfirmation(
|
||||
newPort: number,
|
||||
newColor: Color
|
||||
): Promise<boolean> {
|
||||
const colorCode = newColor === "blue" ? colors.blue : colors.green;
|
||||
log("========================================", "cyan");
|
||||
log(" MANUAL VALIDATION REQUIRED", "yellow");
|
||||
log("========================================", "cyan");
|
||||
logBlank();
|
||||
log(` Test the new ${colorCode}${newColor}${colors.reset} deployment at:`);
|
||||
log(` ${colors.bold}https://${newColor}.api.webamp.org${colors.reset}`);
|
||||
logBlank();
|
||||
log(` You can test it with:`);
|
||||
log(
|
||||
` ${colors.cyan}curl -I https://${newColor}.api.webamp.org${colors.reset}`
|
||||
);
|
||||
logBlank();
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(" Does everything look good? (yes/no): ", (answer) => {
|
||||
rl.close();
|
||||
logBlank();
|
||||
resolve(answer.toLowerCase() === "yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function switchApacheConfig(state: DeploymentState): void {
|
||||
const colorCode = state.newColor === "blue" ? colors.blue : colors.green;
|
||||
log(
|
||||
`→ Switching production to ${colorCode}${state.newColor}${colors.reset}...`,
|
||||
"cyan"
|
||||
);
|
||||
log(" Updating Apache configuration...", "cyan");
|
||||
|
||||
// Backup current config
|
||||
const backupPath = `${APACHE_CONFIG}.backup`;
|
||||
copyFileSync(APACHE_CONFIG, backupPath);
|
||||
|
||||
// Update the port in Apache config
|
||||
exec(
|
||||
`sudo sed -i 's/localhost:${state.currentPort}/localhost:${state.newPort}/g' "${APACHE_CONFIG}"`,
|
||||
"update Apache configuration"
|
||||
);
|
||||
|
||||
// Reload Apache
|
||||
exec("sudo systemctl reload apache2", "reload Apache");
|
||||
|
||||
log("✓ Apache configuration updated and reloaded", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Verify the change
|
||||
const updatedConfig = readFileSync(APACHE_CONFIG, "utf8");
|
||||
if (!updatedConfig.includes(`localhost:${state.newPort}`)) {
|
||||
throw new Error("Configuration update verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBackup(): void {
|
||||
log(" Restoring backup...", "yellow");
|
||||
const backupPath = `${APACHE_CONFIG}.backup`;
|
||||
exec(`sudo cp "${backupPath}" "${APACHE_CONFIG}"`, "restore backup");
|
||||
exec("sudo systemctl reload apache2", "reload Apache");
|
||||
log("✓ Backup restored", "yellow");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
log("========================================", "cyan");
|
||||
log(" Blue/Green Deployment Script", "cyan");
|
||||
log("========================================", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Step 1: Detect current deployment
|
||||
const state = detectCurrentDeployment();
|
||||
|
||||
// Step 2: Pull from GitHub
|
||||
log("→ Pulling latest code from GitHub...", "cyan");
|
||||
exec("git pull --rebase origin master", "pull from GitHub");
|
||||
log("✓ Code updated", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Step 3: Ensure correct Node version
|
||||
log("→ Ensuring correct Node version...", "cyan");
|
||||
exec(
|
||||
"source ~/.nvm/nvm.sh && nvm install",
|
||||
"ensure correct Node version from .nvmrc"
|
||||
);
|
||||
log("✓ Node version verified", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Step 4: Install dependencies
|
||||
log("→ Installing dependencies...", "cyan");
|
||||
exec(
|
||||
"source ~/.nvm/nvm.sh && nvm exec yarn install --frozen-lockfile",
|
||||
"install dependencies"
|
||||
);
|
||||
log("✓ Dependencies installed", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Step 5: Build the site
|
||||
log("→ Building the site...", "cyan");
|
||||
exec("source ~/.nvm/nvm.sh && nvm exec yarn build", "build the site");
|
||||
log("✓ Build complete", "cyan");
|
||||
logBlank();
|
||||
|
||||
// Step 5: Deploy to inactive instance
|
||||
const newColorCode = state.newColor === "blue" ? colors.blue : colors.green;
|
||||
log(
|
||||
`→ Restarting ${newColorCode}${state.newColor}${colors.reset} instance...`,
|
||||
"cyan"
|
||||
);
|
||||
exec(
|
||||
`pm2 restart skin-database-${state.newColor}`,
|
||||
`restart ${state.newColor} instance`
|
||||
);
|
||||
log(
|
||||
`✓ ${newColorCode}${state.newColor}${colors.reset} instance restarted`,
|
||||
"cyan"
|
||||
);
|
||||
logBlank();
|
||||
|
||||
// Wait for the service to start
|
||||
log("→ Waiting for service to be ready...", "cyan");
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
|
||||
// Check if the service is running
|
||||
const pm2List = execSilent("pm2 list");
|
||||
const isRunning =
|
||||
pm2List.includes(`skin-database-${state.newColor}`) &&
|
||||
pm2List.includes("online");
|
||||
|
||||
if (isRunning) {
|
||||
log(
|
||||
`✓ ${newColorCode}${state.newColor}${colors.reset} instance is running`,
|
||||
"cyan"
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
`✗ ${newColorCode}${state.newColor}${colors.reset} instance failed to start!`,
|
||||
"red"
|
||||
);
|
||||
log(` Check PM2 logs: pm2 logs skin-database-${state.newColor}`, "red");
|
||||
process.exit(1);
|
||||
}
|
||||
logBlank();
|
||||
|
||||
// Step 6: Manual validation prompt
|
||||
const confirmed = await promptForConfirmation(
|
||||
state.newPort,
|
||||
state.newColor
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
log("✗ Deployment cancelled!", "red");
|
||||
log(
|
||||
` The ${newColorCode}${state.newColor}${colors.reset} instance is running but not active in production.`
|
||||
);
|
||||
log(
|
||||
` You can rollback by restarting: pm2 restart skin-database-${state.newColor}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 7: Switch Apache configuration
|
||||
switchApacheConfig(state);
|
||||
|
||||
// Success message
|
||||
const currentColorCode =
|
||||
state.currentColor === "blue" ? colors.blue : colors.green;
|
||||
log("========================================", "cyan");
|
||||
log(" DEPLOYMENT SUCCESSFUL!", "cyan");
|
||||
log("========================================", "cyan");
|
||||
logBlank();
|
||||
log(
|
||||
` Active deployment: ${newColorCode}${state.newColor}${colors.reset} (port ${state.newPort})`
|
||||
);
|
||||
log(
|
||||
` Previous deployment: ${currentColorCode}${state.currentColor}${colors.reset} (port ${state.currentPort}) - still running as backup`
|
||||
);
|
||||
logBlank();
|
||||
log("Note: If you need to rollback:", "yellow");
|
||||
log(` 1. Edit: ${APACHE_CONFIG}`);
|
||||
log(` 2. Change port back to ${state.currentPort}`);
|
||||
log(` 3. Run: sudo systemctl reload apache2`);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Configuration update verification failed"
|
||||
) {
|
||||
log("✗ Configuration update failed!", "red");
|
||||
restoreBackup();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the deployment
|
||||
main();
|
||||
|
|
@ -7,10 +7,10 @@ source "$NVM_DIR/nvm.sh"
|
|||
nvm use 20
|
||||
|
||||
# Install dependencies
|
||||
yarn install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build the site
|
||||
yarn run build
|
||||
pnpm run build
|
||||
|
||||
# Reload processes via PM2
|
||||
pm2 reload ecosystem.config.js
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import fetch from "node-fetch";
|
||||
import { exec } from "../utils";
|
||||
import { execFile } from "../utils";
|
||||
import path from "path";
|
||||
|
||||
// Path to the ia command in the virtual environment
|
||||
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
|
||||
|
||||
// Environment variables for the virtual environment
|
||||
const getVenvEnv = () => ({
|
||||
...process.env,
|
||||
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
|
||||
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
|
||||
});
|
||||
|
||||
export async function fetchMetadata(identifier: string): Promise<any> {
|
||||
const r = await fetch(`https://archive.org/metadata/${identifier}`);
|
||||
|
|
@ -11,9 +22,8 @@ export async function fetchMetadata(identifier: string): Promise<any> {
|
|||
}
|
||||
|
||||
export async function fetchTasks(identifier: string): Promise<any> {
|
||||
const command = `ia tasks ${identifier}`;
|
||||
const result = await exec(command, {
|
||||
encoding: "utf8",
|
||||
const result = await execFile(IA_COMMAND, ["tasks", identifier], {
|
||||
env: getVenvEnv(),
|
||||
});
|
||||
return result.stdout
|
||||
.trim()
|
||||
|
|
@ -25,12 +35,31 @@ export async function uploadFile(
|
|||
identifier: string,
|
||||
filepath: string
|
||||
): Promise<any> {
|
||||
const command = `ia upload ${identifier} "${filepath}"`;
|
||||
await exec(command, { encoding: "utf8" });
|
||||
await execFile(IA_COMMAND, ["upload", identifier, filepath], {
|
||||
env: getVenvEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFiles(
|
||||
identifier: string,
|
||||
filepaths: string[],
|
||||
metadata?: { [key: string]: string }
|
||||
): Promise<any> {
|
||||
const args = ["upload", identifier, ...filepaths];
|
||||
|
||||
if (metadata) {
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
args.push(`--metadata=${key}:${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
|
||||
}
|
||||
|
||||
export async function identifierExists(identifier: string): Promise<boolean> {
|
||||
const result = await exec(`ia metadata ${identifier}`);
|
||||
const result = await execFile(IA_COMMAND, ["metadata", identifier], {
|
||||
env: getVenvEnv(),
|
||||
});
|
||||
const data = JSON.parse(result.stdout);
|
||||
return Object.keys(data).length > 0;
|
||||
}
|
||||
|
|
@ -40,7 +69,10 @@ export async function setMetadata(
|
|||
data: { [key: string]: string }
|
||||
) {
|
||||
const pairs = Object.entries(data).map(([key, value]) => `${key}:${value}`);
|
||||
const args = pairs.map((pair) => `--modify="${pair}"`);
|
||||
const command = `ia metadata ${identifier} ${args.join(" ")}`;
|
||||
await exec(command);
|
||||
const args = [
|
||||
"metadata",
|
||||
identifier,
|
||||
...pairs.map((pair) => `--modify=${pair}`),
|
||||
];
|
||||
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
|
||||
}
|
||||
|
|
|
|||
172
packages/skin-database/tasks/bluesky.ts
Normal file
172
packages/skin-database/tasks/bluesky.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import * as Skins from "../data/skins";
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyFeedPost,
|
||||
AtpAgent,
|
||||
BlobRef,
|
||||
RichText,
|
||||
} from "@atproto/api";
|
||||
import {
|
||||
TWEET_BOT_CHANNEL_ID,
|
||||
BLUESKY_USERNAME,
|
||||
BLUESKY_PASSWORD,
|
||||
} from "../config";
|
||||
import { Client } from "discord.js";
|
||||
import sharp from "sharp";
|
||||
import SkinModel from "../data/SkinModel";
|
||||
import UserContext from "../data/UserContext";
|
||||
import { withBufferAsTempFile } from "../utils";
|
||||
import fs from "fs";
|
||||
|
||||
const agent = new AtpAgent({ service: "https://bsky.social" });
|
||||
|
||||
export async function postToBluesky(
|
||||
discordClient: Client,
|
||||
md5: string | null
|
||||
): Promise<void> {
|
||||
if (md5 == null) {
|
||||
md5 = await Skins.getSkinToPostToBluesky();
|
||||
}
|
||||
if (md5 == null) {
|
||||
console.error("No skins to post to Bluesky");
|
||||
return;
|
||||
}
|
||||
const url = await post(md5);
|
||||
|
||||
console.log("Going to post to discord");
|
||||
const tweetBotChannel = await discordClient.channels.fetch(
|
||||
TWEET_BOT_CHANNEL_ID
|
||||
);
|
||||
// @ts-ignore
|
||||
await tweetBotChannel.send(url);
|
||||
console.log("Posted to discord");
|
||||
}
|
||||
|
||||
async function post(md5: string): Promise<string> {
|
||||
const ctx = new UserContext();
|
||||
const skin = await SkinModel.fromMd5Assert(ctx, md5);
|
||||
const screenshot = await Skins.getScreenshotBuffer(md5);
|
||||
const { width, height } = await sharp(screenshot).metadata();
|
||||
|
||||
const image = await sharp(screenshot)
|
||||
.resize(width * 2, height * 2, {
|
||||
kernel: sharp.kernel.nearest,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const name = await skin.getFileName();
|
||||
const url = skin.getMuseumUrl();
|
||||
const screenshotFileName = await skin.getScreenshotFileName();
|
||||
|
||||
const status = `${name}\n`; // TODO: Should we add hashtags?
|
||||
|
||||
await agent.login({
|
||||
identifier: BLUESKY_USERNAME!,
|
||||
password: BLUESKY_PASSWORD!,
|
||||
});
|
||||
|
||||
const blob = await withBufferAsTempFile(
|
||||
image,
|
||||
screenshotFileName,
|
||||
async (filePath) => {
|
||||
return uploadImageFromFilePath(agent, filePath);
|
||||
}
|
||||
);
|
||||
|
||||
const postData = await buildPost(
|
||||
agent,
|
||||
status,
|
||||
buildImageEmbed(blob, width * 2, height * 2)
|
||||
);
|
||||
const postResp = await agent.post(postData);
|
||||
console.log(postResp);
|
||||
|
||||
const postId = postResp.cid;
|
||||
const postUrl = postResp.uri;
|
||||
|
||||
await Skins.markAsPostedToBlueSky(md5, postId, postUrl);
|
||||
|
||||
const prefix = "Try on the ";
|
||||
const suffix = "Winamp Skin Museum";
|
||||
|
||||
agent.post({
|
||||
text: prefix + suffix,
|
||||
createdAt: new Date().toISOString(),
|
||||
facets: [
|
||||
{
|
||||
$type: "app.bsky.richtext.facet",
|
||||
index: {
|
||||
byteStart: prefix.length,
|
||||
byteEnd: prefix.length + suffix.length,
|
||||
},
|
||||
features: [
|
||||
{
|
||||
$type: "app.bsky.richtext.facet#link",
|
||||
uri: url,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
root: postResp,
|
||||
parent: postResp,
|
||||
},
|
||||
$type: "app.bsky.feed.post",
|
||||
});
|
||||
|
||||
// return permalink;
|
||||
return postUrl;
|
||||
}
|
||||
|
||||
/** Build the embed data for an image. */
|
||||
function buildImageEmbed(
|
||||
imgBlob: BlobRef,
|
||||
width: number,
|
||||
height: number
|
||||
): AppBskyEmbedImages.Main {
|
||||
const image = {
|
||||
image: imgBlob,
|
||||
aspectRatio: { width, height },
|
||||
alt: "",
|
||||
};
|
||||
return {
|
||||
$type: "app.bsky.embed.images",
|
||||
images: [image],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the post data for an image. */
|
||||
async function buildPost(
|
||||
agent: AtpAgent,
|
||||
rawText: string,
|
||||
imageEmbed: AppBskyEmbedImages.Main
|
||||
): Promise<AppBskyFeedPost.Record> {
|
||||
const rt = new RichText({ text: rawText });
|
||||
await rt.detectFacets(agent);
|
||||
const { text, facets } = rt;
|
||||
return {
|
||||
text,
|
||||
facets,
|
||||
$type: "app.bsky.feed.post",
|
||||
createdAt: new Date().toISOString(),
|
||||
embed: {
|
||||
$type: "app.bsky.embed.recordWithMedia",
|
||||
...imageEmbed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Upload an image from a URL to Bluesky. */
|
||||
async function uploadImageFromFilePath(
|
||||
agent: AtpAgent,
|
||||
filePath: string
|
||||
): Promise<BlobRef> {
|
||||
const imageBuff = fs.readFileSync(filePath);
|
||||
const imgU8 = new Uint8Array(imageBuff);
|
||||
|
||||
const dstResp = await agent.uploadBlob(imgU8);
|
||||
if (!dstResp.success) {
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
return dstResp.data.blob;
|
||||
}
|
||||
181
packages/skin-database/tasks/computeScrollRanking.ts
Normal file
181
packages/skin-database/tasks/computeScrollRanking.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { knex } from "../db";
|
||||
import type { UserEvent } from "../app/(modern)/scroll/Events";
|
||||
|
||||
const WEIGHTS = {
|
||||
viewDurationPerSecond: 0.1,
|
||||
like: 10,
|
||||
download: 5,
|
||||
share: 15,
|
||||
readmeExpand: 2,
|
||||
};
|
||||
|
||||
interface SessionAggregate {
|
||||
skinViewDurations: Map<string, number>;
|
||||
skinsLiked: Set<string>;
|
||||
skinsDownloaded: Set<string>;
|
||||
readmesExpanded: Set<string>;
|
||||
sharesSucceeded: Set<string>;
|
||||
}
|
||||
|
||||
interface SkinRanking {
|
||||
skinMd5: string;
|
||||
totalViewDurationMs: number;
|
||||
viewCount: number;
|
||||
averageViewDurationMs: number;
|
||||
likeCount: number;
|
||||
downloadCount: number;
|
||||
shareCount: number;
|
||||
readmeExpandCount: number;
|
||||
rankingScore: number;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const rankings = await computeSkinRankings();
|
||||
console.log(JSON.stringify(rankings, null, 2));
|
||||
} catch (error) {
|
||||
console.error("Error during aggregation:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await knex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
export async function computeSkinRankings(): Promise<SkinRanking[]> {
|
||||
const sessionMap = await buildSessionAggregates();
|
||||
|
||||
const skinDataMap = new Map<
|
||||
string,
|
||||
{
|
||||
viewDurations: number[];
|
||||
likes: number;
|
||||
downloads: number;
|
||||
shares: number;
|
||||
readmeExpands: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function getSkinData(skinMd5: string) {
|
||||
if (!skinDataMap.has(skinMd5)) {
|
||||
skinDataMap.set(skinMd5, {
|
||||
viewDurations: [],
|
||||
likes: 0,
|
||||
downloads: 0,
|
||||
shares: 0,
|
||||
readmeExpands: 0,
|
||||
});
|
||||
}
|
||||
return skinDataMap.get(skinMd5)!;
|
||||
}
|
||||
|
||||
for (const session of sessionMap.values()) {
|
||||
for (const [skinMd5, duration] of session.skinViewDurations) {
|
||||
getSkinData(skinMd5).viewDurations.push(duration);
|
||||
}
|
||||
for (const skinMd5 of session.skinsLiked) {
|
||||
getSkinData(skinMd5).likes++;
|
||||
}
|
||||
for (const skinMd5 of session.skinsDownloaded) {
|
||||
getSkinData(skinMd5).downloads++;
|
||||
}
|
||||
for (const skinMd5 of session.sharesSucceeded) {
|
||||
getSkinData(skinMd5).shares++;
|
||||
}
|
||||
for (const skinMd5 of session.readmesExpanded) {
|
||||
getSkinData(skinMd5).readmeExpands++;
|
||||
}
|
||||
}
|
||||
|
||||
const rankings: SkinRanking[] = [];
|
||||
|
||||
for (const [skinMd5, data] of skinDataMap) {
|
||||
const totalViewDurationMs = data.viewDurations.reduce(
|
||||
(sum, duration) => sum + duration,
|
||||
0
|
||||
);
|
||||
const viewCount = data.viewDurations.length;
|
||||
const averageViewDurationMs =
|
||||
viewCount > 0 ? totalViewDurationMs / viewCount : 0;
|
||||
|
||||
const rankingScore =
|
||||
(averageViewDurationMs / 1000) * WEIGHTS.viewDurationPerSecond +
|
||||
data.likes * WEIGHTS.like +
|
||||
data.downloads * WEIGHTS.download +
|
||||
data.shares * WEIGHTS.share +
|
||||
data.readmeExpands * WEIGHTS.readmeExpand;
|
||||
|
||||
rankings.push({
|
||||
skinMd5,
|
||||
totalViewDurationMs,
|
||||
viewCount,
|
||||
averageViewDurationMs,
|
||||
likeCount: data.likes,
|
||||
downloadCount: data.downloads,
|
||||
shareCount: data.shares,
|
||||
readmeExpandCount: data.readmeExpands,
|
||||
rankingScore,
|
||||
});
|
||||
}
|
||||
|
||||
rankings.sort((a, b) => b.rankingScore - a.rankingScore);
|
||||
return rankings;
|
||||
}
|
||||
|
||||
async function buildSessionAggregates(): Promise<
|
||||
Map<string, SessionAggregate>
|
||||
> {
|
||||
const events = await knex("user_log_events")
|
||||
.select("session_id", "timestamp", "metadata")
|
||||
.orderBy("timestamp", "asc");
|
||||
|
||||
const sessionMap = new Map<string, SessionAggregate>();
|
||||
|
||||
function getSession(sessionId: string): SessionAggregate {
|
||||
if (!sessionMap.has(sessionId)) {
|
||||
sessionMap.set(sessionId, {
|
||||
skinViewDurations: new Map(),
|
||||
skinsLiked: new Set(),
|
||||
skinsDownloaded: new Set(),
|
||||
readmesExpanded: new Set(),
|
||||
sharesSucceeded: new Set(),
|
||||
});
|
||||
}
|
||||
return sessionMap.get(sessionId)!;
|
||||
}
|
||||
|
||||
for (const row of events) {
|
||||
const event: UserEvent = JSON.parse(row.metadata);
|
||||
const session = getSession(row.session_id);
|
||||
|
||||
switch (event.type) {
|
||||
case "skin_view_end":
|
||||
session.skinViewDurations.set(event.skinMd5, event.durationMs);
|
||||
break;
|
||||
case "readme_expand":
|
||||
session.readmesExpanded.add(event.skinMd5);
|
||||
break;
|
||||
case "skin_download":
|
||||
session.skinsDownloaded.add(event.skinMd5);
|
||||
break;
|
||||
case "skin_like":
|
||||
if (event.liked) {
|
||||
session.skinsLiked.add(event.skinMd5);
|
||||
} else {
|
||||
session.skinsLiked.delete(event.skinMd5);
|
||||
}
|
||||
break;
|
||||
case "share_success":
|
||||
session.sharesSucceeded.add(event.skinMd5);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sessionMap;
|
||||
}
|
||||
|
||||
export { buildSessionAggregates };
|
||||
export type { SessionAggregate };
|
||||
|
|
@ -7,6 +7,7 @@ import * as Skins from "../data/skins";
|
|||
import * as CloudFlare from "../CloudFlare";
|
||||
import SkinModel from "../data/SkinModel";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-require-imports
|
||||
const Shooter = require("../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 IaItemModel from "../data/IaItemModel";
|
||||
import DiscordEventHandler from "../api/DiscordEventHandler";
|
||||
import { exec } from "../utils";
|
||||
import { execFile } from "../utils";
|
||||
import * as IAService from "../services/internetArchive";
|
||||
|
||||
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> {
|
||||
const filename = await skin.getFileName();
|
||||
const filename = await skin.getFileName(true);
|
||||
|
||||
const screenshotFilename = await skin.getScreenshotFileName();
|
||||
const title = `Winamp Skin: ${filename}`;
|
||||
|
|
@ -207,8 +207,36 @@ export async function archive(skin: SkinModel): Promise<string> {
|
|||
|
||||
console.log(`Going to try to upload with identifier "${identifier}"...`);
|
||||
|
||||
const command = `ia upload ${identifier} "${skinFile}" "${screenshotFile}" --metadata="collection:winampskins" --metadata="skintype:wsz" --metadata="mediatype:software" --metadata="title:${title}"`;
|
||||
await exec(command, { encoding: "utf8" });
|
||||
// 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 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 });
|
||||
return identifier;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,7 @@
|
|||
"outDir": "dist",
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
|
|
@ -37,13 +33,9 @@
|
|||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/types/app/(legacy)/about/page.tsx"
|
||||
],
|
||||
"lib": [
|
||||
"es2015"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
"lib": ["es2015"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export type SkinRow = {
|
|||
skin_type: number;
|
||||
emails: string;
|
||||
// readme_text: string;
|
||||
average_color: string;
|
||||
average_color?: string;
|
||||
};
|
||||
|
||||
export type TweetRow = {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import child_process from "child_process";
|
|||
import path from "path";
|
||||
|
||||
export const exec = util.promisify(child_process.exec);
|
||||
export const execFile = util.promisify(child_process.execFile);
|
||||
|
||||
const temp = _temp.track();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
|||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
"dependencies": {
|
||||
"@vercel/og": "^0.0.20",
|
||||
"next": "13.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vercel": "^28.4.17"
|
||||
|
|
|
|||
|
|
@ -98,11 +98,10 @@ async function searchImage(query) {
|
|||
});
|
||||
}
|
||||
|
||||
function HeaderGrid({ skins, title }) {
|
||||
function HeaderGrid({ skins, _title }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "black",
|
||||
color: "white",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
|
@ -110,7 +109,6 @@ function HeaderGrid({ skins, title }) {
|
|||
flexDirection: "column",
|
||||
textAlign: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ export default function Frame({ children }) {
|
|||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "black",
|
||||
color: "white",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
|
@ -10,7 +9,6 @@ export default function Frame({ children }) {
|
|||
flexDirection: "column",
|
||||
textAlign: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
justifyContent: "space-between",
|
||||
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:
|
||||
|
||||
```js
|
||||
import Webamp from "webamp";
|
||||
import Webamp from "webamp/butterchurn";
|
||||
// ... use Webamp here
|
||||
```
|
||||
|
||||
|
|
@ -25,19 +25,7 @@ ES modules can be imported via URL directly inside a `<script type="module">` ta
|
|||
|
||||
```html
|
||||
<script type="module">
|
||||
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;
|
||||
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
|
||||
// ... use Webamp here
|
||||
</script>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ Create a DOM element somewhere in your HTML document. This will be used by Webam
|
|||
|
||||
:::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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
The [next release](../12_changelog.md#unreleased) of Webamp will includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled.
|
||||
Starting with [v2.2.0](../12_changelog.md#220) Webamp includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled. See "Minimal Milkdrop" in [examples](../04_examples.md) for more details.
|
||||
|
||||
## Hotkeys
|
||||
|
||||
|
|
|
|||
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