diff --git a/.eslintrc b/.eslintrc index 213b7eff..04e3e07c 100644 --- a/.eslintrc +++ b/.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": "^_" + } + ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28e61a06..5d6a8a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 829ecf68..0f1fe805 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .vscode +.idea dist # Turborepo cache diff --git a/README.md b/README.md index 42057cff..50aa0ede 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 6cba5378..00000000 --- a/deploy.sh +++ /dev/null @@ -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 \ No newline at end of file diff --git a/examples/lazy/.gitignore b/examples/lazy/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/lazy/.gitignore @@ -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? diff --git a/examples/lazy/README.md b/examples/lazy/README.md new file mode 100644 index 00000000..2e22adf4 --- /dev/null +++ b/examples/lazy/README.md @@ -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. diff --git a/examples/lazy/index.html b/examples/lazy/index.html new file mode 100644 index 00000000..e6da3966 --- /dev/null +++ b/examples/lazy/index.html @@ -0,0 +1,13 @@ + + + + + + + Webamp + + +
+ + + diff --git a/examples/lazy/package.json b/examples/lazy/package.json new file mode 100644 index 00000000..8568daef --- /dev/null +++ b/examples/lazy/package.json @@ -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" + } +} diff --git a/examples/lazy/src/main.ts b/examples/lazy/src/main.ts new file mode 100644 index 00000000..836906fe --- /dev/null +++ b/examples/lazy/src/main.ts @@ -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")!); diff --git a/examples/lazy/src/vite-env.d.ts b/examples/lazy/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/lazy/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/lazy/tsconfig.json b/examples/lazy/tsconfig.json new file mode 100644 index 00000000..4f5edc24 --- /dev/null +++ b/examples/lazy/tsconfig.json @@ -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"] +} diff --git a/examples/minimal/index.html b/examples/minimal/index.html index fbd6b4cd..41e72913 100755 --- a/examples/minimal/index.html +++ b/examples/minimal/index.html @@ -10,7 +10,7 @@ + + diff --git a/netlify.toml b/netlify.toml index c80f98a6..0b597cc1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -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 diff --git a/package.json b/package.json index edb7328e..ce42d467 100644 --- a/package.json +++ b/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" + } + } } diff --git a/packages/ani-cursor/package.json b/packages/ani-cursor/package.json index 0619a051..f03fb1a6 100644 --- a/packages/ani-cursor/package.json +++ b/packages/ani-cursor/package.json @@ -32,6 +32,7 @@ "scripts": { "build": "tsc", "type-check": "tsc --noEmit", + "lint": "eslint src --ext ts,js", "test": "jest", "prepublish": "tsc" }, diff --git a/packages/skin-database/.eslintrc.js b/packages/skin-database/.eslintrc.js index 08ad143d..ca0786ad 100644 --- a/packages/skin-database/.eslintrc.js +++ b/packages/skin-database/.eslintrc.js @@ -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/**"], }; diff --git a/packages/skin-database/addSkin.ts b/packages/skin-database/addSkin.ts index 1d879d44..10d45cca 100644 --- a/packages/skin-database/addSkin.ts +++ b/packages/skin-database/addSkin.ts @@ -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" }; } diff --git a/packages/skin-database/api/graphql/index.ts b/packages/skin-database/api/graphql/index.ts index f6f43393..10b59dec 100644 --- a/packages/skin-database/api/graphql/index.ts +++ b/packages/skin-database/api/graphql/index.ts @@ -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; diff --git a/packages/skin-database/api/graphql/resolvers/BulkDownloadConnection.ts b/packages/skin-database/api/graphql/resolvers/BulkDownloadConnection.ts new file mode 100644 index 00000000..bc97e0b2 --- /dev/null +++ b/packages/skin-database/api/graphql/resolvers/BulkDownloadConnection.ts @@ -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 { + // 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 { + 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> { + // Get skins ordered by skin_type (classic first, then modern) and id for consistency + const skins = await knex("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); +} diff --git a/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts index ae879bd2..da9433c7 100644 --- a/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ClassicSkinResolver.ts @@ -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 { - 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 { return this._model.getIsNsfw(); } - average_color(): string { + average_color(): string | null { return this._model.getAverageColor(); } /** diff --git a/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts index c9d343a2..1ecd3800 100644 --- a/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/CommonSkinResolver.ts @@ -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 { - 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}`; } /** diff --git a/packages/skin-database/api/graphql/schema.graphql b/packages/skin-database/api/graphql/schema.graphql index 9eb0c82c..5379b060 100644 --- a/packages/skin-database/api/graphql/schema.graphql +++ b/packages/skin-database/api/graphql/schema.graphql @@ -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 diff --git a/packages/skin-database/api/graphql/schema.ts b/packages/skin-database/api/graphql/schema.ts index 7b9f927a..c6f19209 100644 --- a/packages/skin-database/api/graphql/schema.ts +++ b/packages/skin-database/api/graphql/schema.ts @@ -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(value: T | Promise): Promise { 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] }); } diff --git a/packages/skin-database/api/processUserUploads.ts b/packages/skin-database/api/processUserUploads.ts index 25462fd0..37999e1f 100644 --- a/packages/skin-database/api/processUserUploads.ts +++ b/packages/skin-database/api/processUserUploads.ts @@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60; function timeout(p: Promise, duration: number): Promise { return Promise.race([ p, - new Promise((resolve, reject) => - setTimeout(() => reject("timeout"), duration) - ), + new Promise((_resolve, reject) => { + setTimeout(() => reject("timeout"), duration); + }), ]); } diff --git a/packages/skin-database/app/(legacy)/about/page.tsx b/packages/skin-database/app/(legacy)/about/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/packages/skin-database/app/(legacy)/about/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/packages/skin-database/app/(legacy)/feedback/page.tsx b/packages/skin-database/app/(legacy)/feedback/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/packages/skin-database/app/(legacy)/feedback/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/packages/skin-database/app/(legacy)/layout.tsx b/packages/skin-database/app/(legacy)/layout.tsx new file mode 100644 index 00000000..f13532e7 --- /dev/null +++ b/packages/skin-database/app/(legacy)/layout.tsx @@ -0,0 +1,5 @@ +import App from "../App"; + +export default function Layout() { + return ; +} diff --git a/packages/skin-database/app/skin/[hash]/[fileName]/page.tsx b/packages/skin-database/app/(legacy)/skin/[hash]/[fileName]/page.tsx similarity index 85% rename from packages/skin-database/app/skin/[hash]/[fileName]/page.tsx rename to packages/skin-database/app/(legacy)/skin/[hash]/[fileName]/page.tsx index 0000b2eb..2316d806 100644 --- a/packages/skin-database/app/skin/[hash]/[fileName]/page.tsx +++ b/packages/skin-database/app/(legacy)/skin/[hash]/[fileName]/page.tsx @@ -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 { } export default function Page() { - return ; + return null; } diff --git a/packages/skin-database/app/(legacy)/skin/[hash]/debug/[fileName]/page.tsx b/packages/skin-database/app/(legacy)/skin/[hash]/debug/[fileName]/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/packages/skin-database/app/(legacy)/skin/[hash]/debug/[fileName]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/packages/skin-database/app/(legacy)/skin/[hash]/debug/page.tsx b/packages/skin-database/app/(legacy)/skin/[hash]/debug/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/packages/skin-database/app/(legacy)/skin/[hash]/debug/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/packages/skin-database/app/skin/[hash]/page.tsx b/packages/skin-database/app/(legacy)/skin/[hash]/page.tsx similarity index 85% rename from packages/skin-database/app/skin/[hash]/page.tsx rename to packages/skin-database/app/(legacy)/skin/[hash]/page.tsx index ad767245..0dc9fbf1 100644 --- a/packages/skin-database/app/skin/[hash]/page.tsx +++ b/packages/skin-database/app/(legacy)/skin/[hash]/page.tsx @@ -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 { } export default function Page() { - return ; + return null; } diff --git a/packages/skin-database/app/skin/[hash]/skinMetadata.ts b/packages/skin-database/app/(legacy)/skin/[hash]/skinMetadata.ts similarity index 91% rename from packages/skin-database/app/skin/[hash]/skinMetadata.ts rename to packages/skin-database/app/(legacy)/skin/[hash]/skinMetadata.ts index a7685659..d8ffe9b9 100644 --- a/packages/skin-database/app/skin/[hash]/skinMetadata.ts +++ b/packages/skin-database/app/(legacy)/skin/[hash]/skinMetadata.ts @@ -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 diff --git a/packages/skin-database/app/(legacy)/upload/page.tsx b/packages/skin-database/app/(legacy)/upload/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/packages/skin-database/app/(legacy)/upload/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx b/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx new file mode 100644 index 00000000..d449a757 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx @@ -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(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 && ( +
+
+ } + label="About" + onClick={() => { + setIsHamburgerOpen(false); + }} + /> + } + label="Feedback" + onClick={() => { + setIsHamburgerOpen(false); + }} + /> + } + label="GitHub" + onClick={() => { + setIsHamburgerOpen(false); + }} + external + /> +
+
+ )} + + {/* Bottom Menu Bar */} +
+
+ } + label="Grid" + isActive={pathname === "/scroll"} + /> + } + label="Feed" + isActive={pathname.startsWith("/scroll/skin")} + /> + } + label="Upload" + isActive={pathname === "/upload"} + /> + } + label="Menu" + onClick={toggleHamburger} + isButton + isActive={false} + /> +
+
+ + ); +} + +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) => { + e.currentTarget.style.color = "#fff"; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + e.currentTarget.style.color = "#ccc"; + }; + + const content = ( + <> + {/* Active indicator line */} + {isActive && ( + +
+ + )} +
+ {icon} +
+ + + ); + + if (isButton) { + return ( + + ); + } + + return ( + + {content} + + ); +} + +type HamburgerMenuItemProps = { + href: string; + icon: React.ReactNode; + label: string; + onClick: () => void; + external?: boolean; +}; + +function HamburgerMenuItem({ + href, + icon, + label, + onClick, + external = false, +}: HamburgerMenuItemProps) { + const content = ( +
{ + 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} + + {label} + +
+ ); + + if (external) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts new file mode 100644 index 00000000..c0c0085f --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/Events.ts @@ -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"; + }; diff --git a/packages/skin-database/app/(modern)/scroll/Grid.tsx b/packages/skin-database/app/(modern)/scroll/Grid.tsx new file mode 100644 index 00000000..7920aa05 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/Grid.tsx @@ -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; +}; + +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 ( +
+
+ + + {skin.fileName} + + + {skin.nsfw && ( +
+ NSFW +
+ )} +
+
+ ); +} + +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(initialSkins); + const [loadedPages, setLoadedPages] = useState>(new Set([0])); + const isLoadingRef = useRef(false); + + // State for search mode + const [searchSkins, setSearchSkins] = useState([]); + const [searchError, setSearchError] = useState(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) => { + 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(null); + const itemRef = React.useRef(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 ( +
+ {/* Floating Search Bar */} +
+
+ { + 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)"; + }} + /> +
+
+ + {/* Error State */} + {isSearchMode && searchError && ( +
+ {searchError} +
+ )} + + {/* Empty Results */} + {isSearchMode && !searchError && skins.length === 0 && ( +
+ No results found for "{inputValue}" +
+ )} + + {/* Grid - show when browsing or when we have results (even while pending) */} + {(!isSearchMode || (!searchError && skins.length > 0)) && ( + + {Cell} + + )} +
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/InfiniteScrollGrid.tsx b/packages/skin-database/app/(modern)/scroll/InfiniteScrollGrid.tsx new file mode 100644 index 00000000..ccdc8049 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/InfiniteScrollGrid.tsx @@ -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; + 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
; + } + + return ( +
{ + router.push(`/scroll/skin/${skin.md5}`); + }} + > +
+ {skin.fileName} +
+
+ ); +}); + +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(initialSkins); + const [fetching, setFetching] = useState(false); + const [windowWidth, setWindowWidth] = useState(0); + const [windowHeight, setWindowHeight] = useState(0); + const gridRef = useRef(null); + const requestedIndicesRef = useRef>(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 ( +
+ + {GridCell} + +
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx b/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx new file mode 100644 index 00000000..be57407e --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx @@ -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 ( +
+ + + + +
+ ); +} + +// 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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} diff --git a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx new file mode 100644 index 00000000..3e052faa --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx @@ -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 ( +
+
+ + {skin.fileName} + {showWebamp && ( + {}} + loaded={() => {}} + /> + )} + + + +
+ +
+

+ {skin.fileName} +

+

+ {skin.readmeStart} +

+
+
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx new file mode 100644 index 00000000..18da3b4d --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx @@ -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; + sessionId: string; +}; + +export default function SkinScroller({ + initialSkins, + getSkins, + sessionId, +}: Props) { + const [skins, setSkins] = useState(initialSkins); + const [visibleSkinIndex, setVisibleSkinIndex] = useState(0); + const [fetching, setFetching] = useState(false); + const [containerRef, setContainerRef] = useState(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 ( + <> +
+ {skins.map((skin, i) => { + return ( + + ); + })} +
+ {/* Top shadow overlay */} +
+ + ); +} diff --git a/packages/skin-database/app/(modern)/scroll/StaticPage.tsx b/packages/skin-database/app/(modern)/scroll/StaticPage.tsx new file mode 100644 index 00000000..ee725ebc --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/StaticPage.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} + +// Styled heading components +export function Heading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function Subheading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// Styled link component +export function Link({ + href, + children, + ...props +}: { + href: string; + children: ReactNode; + target?: string; + rel?: string; +}) { + return ( + + {children} + + ); +} + +// Styled paragraph component +export function Paragraph({ children }: { children: ReactNode }) { + return

{children}

; +} + +// Styled form components +export function Label({ children }: { children: ReactNode }) { + return ( + + ); +} + +export function Input({ + style, + ...props +}: React.InputHTMLAttributes & { style?: CSSProperties }) { + return ( + + ); +} + +export function Textarea({ + style, + ...props +}: React.TextareaHTMLAttributes & { + style?: CSSProperties; +}) { + return ( +