diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index d4d96b32..00000000 --- a/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -.git -node_modules -apps/web/node_modules -apps/web/dist -**/*.md diff --git a/.env.example b/.env.example index 18aefa1e..ca997ced 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ POSTGRES_DB=typetype POSTGRES_USER=typetype POSTGRES_PASSWORD=typetype DRAGONFLY_URL=redis://dragonfly:6379 -GITHUB_REPO=Priveetee/TypeType-Server +GITHUB_REPO=TypeType-Video/TypeType GITHUB_ISSUE_TEMPLATE=bug_report_backend.md DOWNLOADER_S3_ACCESS_KEY=SET_ME_ACCESS_KEY DOWNLOADER_S3_SECRET_KEY=SET_ME_SECRET_KEY diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5da944f7..3eb3b635 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Question or help - url: https://github.com/Priveetee/TypeType/discussions + url: https://github.com/TypeType-Video/TypeType/discussions about: "Ask questions and get help in Discussions ;)" diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..306a00e8 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - arko + - typetype diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 778298e5..3fa0b754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,81 +1,34 @@ name: CI on: + push: + branches: ["dev", "main"] pull_request: branches: ["dev", "main"] workflow_call: workflow_dispatch: +permissions: + contents: read + env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - quality: - runs-on: [self-hosted, Linux, X64, arko, typetype] + validate-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6.0.2 - - - uses: oven-sh/setup-bun@v2.2.0 with: - bun-version: "1.3.14" - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Validate release version - run: | - version="$(bun -e 'console.log(require("./package.json").version)')" - web_version="$(bun -e 'console.log(require("./apps/web/package.json").version)')" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "package.json must contain a stable SemVer version" - exit 1 - fi - if [[ "$web_version" != "$version" ]]; then - echo "package.json and apps/web/package.json versions must match" - exit 1 - fi - - tag="v$version" - tag_sha="$(git ls-remote --tags origin "refs/tags/$tag" | cut -f1)" - if [[ -z "$tag_sha" ]]; then - exit 0 - fi - - if [[ "$GITHUB_REF" == "refs/heads/main" && "$tag_sha" == "$GITHUB_SHA" ]]; then - exit 0 - fi - - if [[ "$GITHUB_REF" == "refs/tags/$tag" && "$tag_sha" == "$GITHUB_SHA" ]]; then - exit 0 - fi + submodules: true + - run: ./scripts/validate-stack.sh - echo "$tag already exists; bump package.json and apps/web/package.json before continuing" - exit 1 - - - name: Biome check - run: bun run check - - - name: Test - run: bun run test - - - name: Knip - run: bun run knip - - - name: Sherif - run: bun run sherif - - build: - needs: quality + validate-trusted: + if: github.event_name != 'pull_request' runs-on: [self-hosted, Linux, X64, arko, typetype] steps: - uses: actions/checkout@v6.0.2 - - - uses: oven-sh/setup-bun@v2.2.0 with: - bun-version: "1.3.14" - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build - run: bun run build + submodules: true + - run: ./scripts/validate-stack.sh diff --git a/.github/workflows/component-image.yml b/.github/workflows/component-image.yml new file mode 100644 index 00000000..972b9796 --- /dev/null +++ b/.github/workflows/component-image.yml @@ -0,0 +1,98 @@ +name: Component image + +on: + repository_dispatch: + types: [component-image] + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: typetype-beta-coordination + cancel-in-progress: false + +jobs: + validate: + runs-on: [self-hosted, Linux, X64, arko, typetype] + outputs: + channel: ${{ steps.payload.outputs.channel }} + component: ${{ steps.payload.outputs.component }} + digest: ${{ steps.payload.outputs.digest }} + image: ${{ steps.payload.outputs.image }} + revision: ${{ steps.payload.outputs.revision }} + version: ${{ steps.payload.outputs.version }} + steps: + - name: Validate payload + id: payload + env: + EVENT_NAME: ${{ github.event_name }} + PAYLOAD: ${{ toJSON(github.event.client_payload) }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + { + echo "channel=beta" + echo "component=all" + echo "digest=manual" + echo "image=all beta images" + echo "revision=$GITHUB_SHA" + echo "version=manual" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + channel="$(jq -er '.channel' <<< "$PAYLOAD")" + component="$(jq -er '.component' <<< "$PAYLOAD")" + digest="$(jq -er '.digest' <<< "$PAYLOAD")" + image="$(jq -er '.image' <<< "$PAYLOAD")" + revision="$(jq -er '.revision' <<< "$PAYLOAD")" + version="$(jq -er '.version' <<< "$PAYLOAD")" + + case "$component" in + frontend) image_name="typetype" ;; + server) image_name="typetype-server" ;; + downloader) image_name="typetype-downloader" ;; + token) image_name="typetype-token" ;; + *) exit 64 ;; + esac + case "$channel" in + beta) expected_image="ghcr.io/typetype-video/${image_name}-beta" ;; + stable) expected_image="ghcr.io/typetype-video/${image_name}" ;; + *) exit 64 ;; + esac + + [[ "$image" == "$expected_image" ]] + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + [[ "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$ ]] + + { + echo "channel=$channel" + echo "component=$component" + echo "digest=$digest" + echo "image=$image" + echo "revision=$revision" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + + - name: Record component version + env: + CHANNEL: ${{ steps.payload.outputs.channel }} + COMPONENT: ${{ steps.payload.outputs.component }} + DIGEST: ${{ steps.payload.outputs.digest }} + IMAGE: ${{ steps.payload.outputs.image }} + REVISION: ${{ steps.payload.outputs.revision }} + VERSION: ${{ steps.payload.outputs.version }} + run: | + { + echo "## Component image" + echo + echo "- Channel: \`$CHANNEL\`" + echo "- Component: \`$COMPONENT\`" + echo "- Version: \`$VERSION\`" + echo "- Revision: \`$REVISION\`" + echo "- Image: \`$IMAGE@$DIGEST\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index cf498419..00000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Coverage - -on: - push: - branches: ["dev"] - pull_request: - branches: ["dev", "main"] - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - coverage: - runs-on: [self-hosted, Linux, X64, arko, typetype] - steps: - - uses: actions/checkout@v6.0.2 - - - uses: oven-sh/setup-bun@v2.2.0 - with: - bun-version: "1.3.14" - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Test coverage - run: bun run test:coverage - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: frontend-lcov-report - path: apps/web/coverage diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index e46b30ba..00000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,476 +0,0 @@ -name: Docker - -on: - push: - branches: - - main - - dev - tags: - - "v*" - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: true - -jobs: - verify: - uses: ./.github/workflows/ci.yml - - prepare-image: - needs: verify - runs-on: [self-hosted, Linux, X64, arko, typetype] - permissions: - contents: read - outputs: - image: ${{ steps.build-info.outputs.image }} - labels: ${{ steps.meta.outputs.labels }} - metadata-json: ${{ steps.meta.outputs.json }} - release-tag: ${{ steps.build-info.outputs.release-tag }} - version: ${{ steps.build-info.outputs.version }} - build-time: ${{ steps.build-info.outputs.build-time }} - - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - - - uses: oven-sh/setup-bun@v2.2.0 - with: - bun-version: 1.3.14 - - - name: Resolve build metadata - id: build-info - run: | - base_version="$(bun -e 'console.log(require("./package.json").version)')" - if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - version="${GITHUB_REF_NAME#v}" - release_tag="$GITHUB_REF_NAME" - image="${REGISTRY}/${GITHUB_REPOSITORY,,}" - elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then - version="$base_version" - release_tag="v$base_version" - image="${REGISTRY}/${GITHUB_REPOSITORY,,}" - else - version="$base_version-beta.$GITHUB_RUN_NUMBER" - release_tag="v$version" - image="${REGISTRY}/${GITHUB_REPOSITORY,,}-beta" - fi - IFS=. read -r major minor _ <<< "$base_version" - echo "image=$image" >> "$GITHUB_OUTPUT" - echo "major=$major" >> "$GITHUB_OUTPUT" - echo "major-minor=$major.$minor" >> "$GITHUB_OUTPUT" - echo "release-tag=$release_tag" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "build-time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" - - - name: Validate stable release notes - if: github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v') - env: - RELEASE_VERSION: ${{ steps.build-info.outputs.version }} - run: | - expected_heading="# TypeType $RELEASE_VERSION" - actual_heading="$(sed -n '1p' RELEASE_NOTES.md)" - if [[ "$actual_heading" != "$expected_heading" ]]; then - echo "RELEASE_NOTES.md must start with: $expected_heading" - exit 1 - fi - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v6.0.0 - with: - images: | - name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},enable=${{ github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v') }} - name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-beta,enable=${{ github.ref_name == 'dev' }} - tags: | - type=sha,prefix=sha-,format=short - type=ref,event=branch - type=ref,event=tag - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=${{ steps.build-info.outputs.version }} - type=raw,value=${{ steps.build-info.outputs.major-minor }},enable=${{ github.ref_name == 'main' }} - type=raw,value=${{ steps.build-info.outputs.major }},enable=${{ github.ref_name == 'main' }} - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=latest,enable=${{ github.ref_name == 'dev' }} - type=raw,value=beta,enable=${{ github.ref_name == 'dev' }} - - build-platform: - needs: prepare-image - runs-on: [self-hosted, Linux, X64, arko, typetype] - timeout-minutes: 20 - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - arch: amd64 - - platform: linux/arm64 - arch: arm64 - - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v4.1.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU - if: matrix.arch == 'arm64' - uses: docker/setup-qemu-action@v4.1.0 - with: - platforms: arm64 - cache-image: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.0.0 - - - name: Build and push - id: build - uses: docker/build-push-action@v7.1.0 - with: - context: . - build-args: | - BUILD_VERSION=${{ needs.prepare-image.outputs.version }} - BUILD_REVISION=${{ github.sha }} - BUILD_TIME=${{ needs.prepare-image.outputs.build-time }} - platforms: ${{ matrix.platform }} - pull: true - labels: ${{ needs.prepare-image.outputs.labels }} - outputs: type=image,name=${{ needs.prepare-image.outputs.image }},push-by-digest=true,name-canonical=true,push=true - provenance: false - cache-from: type=gha,scope=typetype-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=typetype-${{ matrix.arch }} - - - name: Export digest - env: - DIGEST: ${{ steps.build.outputs.digest }} - run: | - digest_dir="$RUNNER_TEMP/typetype-digests" - rm -rf "$digest_dir" - mkdir -p "$digest_dir" - touch "$digest_dir/${DIGEST#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v7 - with: - name: digests-${{ matrix.arch }} - path: ${{ runner.temp }}/typetype-digests/* - if-no-files-found: error - retention-days: 1 - - build-and-push: - needs: [prepare-image, build-platform] - runs-on: [self-hosted, Linux, X64, arko, typetype] - timeout-minutes: 10 - permissions: - contents: read - packages: write - outputs: - digest: ${{ steps.manifest.outputs.digest }} - image: ${{ needs.prepare-image.outputs.image }} - release-tag: ${{ needs.prepare-image.outputs.release-tag }} - revision: ${{ github.sha }} - version: ${{ needs.prepare-image.outputs.version }} - - steps: - - name: Prepare digest directory - run: rm -rf "$RUNNER_TEMP/typetype-digests" - - - name: Download digests - uses: actions/download-artifact@v8.0.1 - with: - path: ${{ runner.temp }}/typetype-digests - pattern: digests-* - merge-multiple: true - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v4.1.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.0.0 - - - name: Create manifest list and push - id: manifest - env: - DIGEST_DIR: ${{ runner.temp }}/typetype-digests - IMAGE: ${{ needs.prepare-image.outputs.image }} - METADATA_JSON: ${{ needs.prepare-image.outputs.metadata-json }} - run: | - cd "$DIGEST_DIR" - mapfile -t digests < <(find . -maxdepth 1 -type f -printf '%f\n' | sort) - if [[ "${#digests[@]}" -ne 2 ]]; then - echo "Expected two platform digests, found ${#digests[@]}" - exit 1 - fi - - mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON") - if [[ "${#tags[@]}" -eq 0 ]]; then - echo "No image tags were generated" - exit 1 - fi - - tag_args=() - for tag in "${tags[@]}"; do - tag_args+=(--tag "$tag") - done - - source_args=() - for digest in "${digests[@]}"; do - source_args+=("$IMAGE@sha256:$digest") - done - - docker buildx imagetools create "${tag_args[@]}" "${source_args[@]}" - manifest_json="$(docker buildx imagetools inspect "${tags[0]}" --format '{{json .Manifest}}')" - digest="$(jq -r '.digest' <<< "$manifest_json")" - if [[ "$digest" != sha256:* ]]; then - echo "Published manifest has no valid digest" - exit 1 - fi - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - - name: Verify manifest platforms - env: - IMAGE: ${{ needs.prepare-image.outputs.image }} - VERSION: ${{ needs.prepare-image.outputs.version }} - run: | - platforms="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r '.manifests[].platform | "\(.os)/\(.architecture)"' | sort -u)" - if [[ "$platforms" != $'linux/amd64\nlinux/arm64' ]]; then - printf 'Unexpected manifest platforms:\n%s\n' "$platforms" - exit 1 - fi - - deploy-stable: - needs: build-and-push - if: github.ref_name == 'main' - runs-on: [self-hosted, Linux, X64, arko, typetype] - environment: stable - outputs: - deployed: ${{ steps.deploy-result.outputs.deployed }} - steps: - - name: Check deployment configuration - id: deploy-config - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} - TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} - run: | - if [ -z "$TYPE_TYPE_DEPLOY_HOST" ] || [ -z "$TYPE_TYPE_DEPLOY_PORT" ] || [ -z "$TYPE_TYPE_DEPLOY_USER" ] || [ -z "$TYPE_TYPE_DEPLOY_SSH_KEY" ]; then - echo "Deployment secrets are not configured; skipping deployment." - echo "configured=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "configured=true" >> "$GITHUB_OUTPUT" - - - name: Configure SSH - if: steps.deploy-config.outputs.configured == 'true' - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} - run: | - install -m 600 /dev/null "$RUNNER_TEMP/typetype_deploy_key" - printf '%s\n' "$TYPE_TYPE_DEPLOY_SSH_KEY" > "$RUNNER_TEMP/typetype_deploy_key" - ssh-keyscan -p "$TYPE_TYPE_DEPLOY_PORT" "$TYPE_TYPE_DEPLOY_HOST" > "$RUNNER_TEMP/known_hosts" 2>/dev/null - - - name: Deploy stable stack - if: steps.deploy-config.outputs.configured == 'true' - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} - run: | - printf '%s\n' "$GITHUB_SHA" \ - | ssh -i "$RUNNER_TEMP/typetype_deploy_key" -p "$TYPE_TYPE_DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" "$TYPE_TYPE_DEPLOY_USER@$TYPE_TYPE_DEPLOY_HOST" /usr/local/sbin/typetype-update - curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://watch.typetype.video/api/health - curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://watch.typetype.video/api/downloader/health - - - name: Record stable deployment - id: deploy-result - if: steps.deploy-config.outputs.configured == 'true' - run: echo "deployed=true" >> "$GITHUB_OUTPUT" - - deploy-beta: - needs: build-and-push - if: github.ref_name == 'dev' - runs-on: [self-hosted, Linux, X64, arko, typetype] - environment: beta - outputs: - deployed: ${{ steps.deploy-result.outputs.deployed }} - steps: - - name: Check deployment configuration - id: deploy-config - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} - TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} - run: | - if [ -z "$TYPE_TYPE_DEPLOY_HOST" ] || [ -z "$TYPE_TYPE_DEPLOY_PORT" ] || [ -z "$TYPE_TYPE_DEPLOY_USER" ] || [ -z "$TYPE_TYPE_DEPLOY_SSH_KEY" ]; then - echo "Deployment secrets are not configured; skipping deployment." - echo "configured=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "configured=true" >> "$GITHUB_OUTPUT" - - - name: Configure SSH - if: steps.deploy-config.outputs.configured == 'true' - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }} - run: | - install -m 600 /dev/null "$RUNNER_TEMP/typetype_deploy_key" - printf '%s\n' "$TYPE_TYPE_DEPLOY_SSH_KEY" > "$RUNNER_TEMP/typetype_deploy_key" - ssh-keyscan -p "$TYPE_TYPE_DEPLOY_PORT" "$TYPE_TYPE_DEPLOY_HOST" > "$RUNNER_TEMP/known_hosts" 2>/dev/null - - - name: Deploy beta stack - if: steps.deploy-config.outputs.configured == 'true' - env: - TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }} - TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }} - TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }} - run: | - ssh -i "$RUNNER_TEMP/typetype_deploy_key" -p "$TYPE_TYPE_DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" "$TYPE_TYPE_DEPLOY_USER@$TYPE_TYPE_DEPLOY_HOST" "/usr/local/sbin/typetype-beta-update" - curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://beta.typetype.video/api/health - curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://beta.typetype.video/api/downloader/health - - - name: Record beta deployment - id: deploy-result - if: steps.deploy-config.outputs.configured == 'true' - run: echo "deployed=true" >> "$GITHUB_OUTPUT" - - prerelease: - needs: [build-and-push, deploy-beta] - if: github.ref_name == 'dev' && needs.deploy-beta.outputs.deployed == 'true' - runs-on: [self-hosted, Linux, X64, arko, typetype] - permissions: - contents: write - steps: - - name: Publish GitHub prerelease - env: - GH_TOKEN: ${{ github.token }} - IMAGE_DIGEST: ${{ needs.build-and-push.outputs.digest }} - IMAGE_NAME: ${{ needs.build-and-push.outputs.image }} - RELEASE_TAG: ${{ needs.build-and-push.outputs.release-tag }} - RELEASE_VERSION: ${{ needs.build-and-push.outputs.version }} - REVISION: ${{ needs.build-and-push.outputs.revision }} - run: | - existing_tag="" - if resolved_tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha' 2>/dev/null)"; then - existing_tag="$resolved_tag" - fi - if [[ -n "$existing_tag" && "$existing_tag" != "$REVISION" ]]; then - echo "$RELEASE_TAG already targets another commit" - exit 1 - fi - if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - exit 0 - fi - previous_tag="$( - gh release list \ - --repo "$GITHUB_REPOSITORY" \ - --limit 100 \ - --json tagName,isPrerelease,publishedAt \ - --jq '[.[] | select(.isPrerelease)] | sort_by(.publishedAt) | reverse | .[0].tagName // ""' - )" - notes_file="$RUNNER_TEMP/prerelease-notes.md" - { - echo "## Beta build" - echo - echo "- Version: \`$RELEASE_VERSION\`" - echo "- Commit: [\`${REVISION:0:7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$REVISION)" - echo "- Container image: \`$IMAGE_NAME@$IMAGE_DIGEST\`" - echo - if [[ -n "$previous_tag" ]]; then - echo "## Changes since \`$previous_tag\`" - echo - gh api "repos/$GITHUB_REPOSITORY/compare/$previous_tag...$REVISION" \ - --jq '.commits[] | [.sha, (.commit.message | split("\n")[0])] | @tsv' | - while IFS=$'\t' read -r commit_sha subject; do - printf -- '- [`%.7s`](%s/%s/commit/%s) %s\n' \ - "$commit_sha" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$commit_sha" "$subject" - done - else - echo "## Changes" - echo - gh api "repos/$GITHUB_REPOSITORY/commits/$REVISION" \ - --jq '[.sha, (.commit.message | split("\n")[0])] | @tsv' | - while IFS=$'\t' read -r commit_sha subject; do - printf -- '- [`%.7s`](%s/%s/commit/%s) %s\n' \ - "$commit_sha" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$commit_sha" "$subject" - done - fi - } > "$notes_file" - gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$REVISION" \ - --title "TypeType $RELEASE_VERSION" \ - --prerelease \ - --latest=false \ - --notes-file "$notes_file" - - release: - needs: [build-and-push, deploy-stable] - if: github.ref_name == 'main' && needs.deploy-stable.outputs.deployed == 'true' - runs-on: [self-hosted, Linux, X64, arko, typetype] - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - - - name: Publish GitHub release - env: - GH_TOKEN: ${{ github.token }} - IMAGE_DIGEST: ${{ needs.build-and-push.outputs.digest }} - IMAGE_NAME: ${{ needs.build-and-push.outputs.image }} - RELEASE_TAG: ${{ needs.build-and-push.outputs.release-tag }} - RELEASE_VERSION: ${{ needs.build-and-push.outputs.version }} - REVISION: ${{ needs.build-and-push.outputs.revision }} - run: | - existing_tag="" - if resolved_tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha' 2>/dev/null)"; then - existing_tag="$resolved_tag" - fi - if [[ -n "$existing_tag" && "$existing_tag" != "$REVISION" ]]; then - echo "$RELEASE_TAG already targets another commit; bump the product version" - exit 1 - fi - if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - exit 0 - fi - notes_file="$RUNNER_TEMP/release-notes.md" - cp RELEASE_NOTES.md "$notes_file" - { - echo "## Technical details" - echo - echo "- Version: \`$RELEASE_VERSION\`" - echo "- Commit: [\`${REVISION:0:7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$REVISION)" - echo "- Container image: \`$IMAGE_NAME@$IMAGE_DIGEST\`" - echo - echo "For the complete release history, see [typetype.video/releases](https://typetype.video/releases)." - } >> "$notes_file" - gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$REVISION" \ - --title "TypeType $RELEASE_VERSION" \ - --latest \ - --notes-file "$notes_file" diff --git a/.gitignore b/.gitignore index 06339030..19a93c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,3 @@ -AGENTS.md -CLAUDE.md -Vision.md -response.md -attente.md -Backend.md -question.md node_modules/ dist/ build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4eba14d0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,24 @@ +[submodule "TypeType-Frontend"] + path = TypeType-Frontend + url = https://github.com/TypeType-Video/TypeType-Frontend.git + branch = dev +[submodule "TypeType-Server"] + path = TypeType-Server + url = https://github.com/TypeType-Video/TypeType-Server.git + branch = dev +[submodule "TypeType-Player"] + path = TypeType-Player + url = https://github.com/TypeType-Video/TypeType-Player.git + branch = dev +[submodule "TypeType-Token"] + path = TypeType-Token + url = https://github.com/TypeType-Video/TypeType-Token.git + branch = dev +[submodule "TypeType-Downloader"] + path = TypeType-Downloader + url = https://github.com/TypeType-Video/TypeType-Downloader.git + branch = dev +[submodule "Docs-TypeType"] + path = Docs-TypeType + url = https://github.com/TypeType-Video/Docs-TypeType.git + branch = dev diff --git a/Architecture.md b/Architecture.md deleted file mode 100644 index 8a659c33..00000000 --- a/Architecture.md +++ /dev/null @@ -1,150 +0,0 @@ -# Architecture - -## Overview - -TypeType is a frontend SPA. It renders client-side and talks to backend services over HTTP. - -Runtime responsibilities: - -- TypeType frontend (this repo): UI, routing, state, playback UX -- TypeType-Server: extraction, auth, user data -- TypeType-Token: PO token and subtitle helper for YouTube flows - -## System Boundary - -``` -PipePipeExtractor (Java, GPL v3) - | - v -TypeType-Server (Kotlin/Ktor, GPL v3) - | - v -TypeType frontend (TypeScript/React, MIT) -``` - -The frontend and backend are separate programs. The REST API is the boundary. - -## Frontend Stack - -| Role | Tool | -|---|---| -| Language | TypeScript (strict) | -| Runtime / Package manager | Bun | -| Build | Vite | -| Framework | React | -| Routing | TanStack Router | -| Server state | TanStack Query | -| Client state | Zustand | -| Video player | Vidstack | -| Styling | Tailwind CSS | -| Components | shadcn/ui + Radix UI | -| Lint / Format | Biome | - -## Repository Structure - -``` -TypeType/ -├── apps/ -│ └── web/ -│ ├── src/ -│ │ ├── components/ -│ │ ├── hooks/ -│ │ ├── lib/ -│ │ ├── routes/ -│ │ └── types/ -│ └── public/ -├── Dockerfile -├── docker-compose.yml -└── nginx.conf -``` - -## Runtime Data Flow - -``` -User action - | - v -Route / Hook - | - v -TanStack Query / fetch - | - v -TypeType-Server HTTP API - | - v -JSON response - | - v -UI render update -``` - -## Authentication Model - -TypeType uses JWT auth from TypeType-Server. - -Auth routes: - -- `POST /auth/register` -- `POST /auth/login` -- `POST /auth/refresh` -- `GET /auth/me` -- `POST /auth/guest` -- `POST /auth/reset-password` - -Protected routes use: - -- `Authorization: Bearer ` - -## API Surface (frontend usage) - -Base URL is `VITE_API_URL` (defaults to `/api` in app runtime). - -Public data routes: - -- `/streams`, `/streams/manifest`, `/streams/native-manifest` -- `/search`, `/suggestions`, `/trending` -- `/comments`, `/comments/replies`, `/bullet-comments`, `/channel` -- `/proxy`, `/proxy/storyboard`, `/proxy/nicovideo` - -Protected user routes: - -- `/history`, `/subscriptions`, `/subscriptions/feed`, `/subscriptions/shorts` -- `/playlists`, `/watch-later`, `/progress`, `/favorites`, `/settings` -- `/search-history`, `/blocked/channels`, `/blocked/videos` -- `/recommendations/home`, `/recommendations/shorts` -- `/recommendations/home/metrics`, `/restore/pipepipe`, `/imports/youtube-takeout`, `/bug-reports` - -Protected admin routes: - -- `/admin/users`, `/admin/settings`, `/admin/bug-reports` - -## Notable Behavior Contracts - -- `GET /progress/{videoUrl}` can return `404` when no position exists; frontend treats this as position `0` -- YouTube uses `/streams/native-manifest` first, then fallback to `/streams/manifest` on `422` -- NicoNico can return `422` on `/streams/manifest`; expected for non-DASH cases -- `GET /search-history` supports backend pagination: `page` and `limit`, total from `X-Total-Count` -- Home and Shorts recommendations are fetched without client event reporting - -## Recommendation and Privacy Flow - -- Home feed requests call `/recommendations/home` with `intent` (currently default `auto`). -- Shorts feed requests call `/recommendations/shorts` with `intent` (currently default `auto`). -- Optional offline quality metrics are available via `/recommendations/home/metrics`. - -## Import and Restore Flow - -- YouTube import (`/imports/youtube-takeout`) is job-based: - - create upload job, - - poll status, - - fetch preview, - - commit import, - - read final report. -- PipePipe restore uses `POST /restore/pipepipe?timeMode=raw|normalized` and returns restore counts plus watchedAt range details. - -## License Boundary - -TypeType-Server is GPL v3. This frontend is MIT. - -No backend source code, classes, or shared modules are imported into the frontend. Integration is HTTP-only. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 436035f0..00000000 --- a/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS builder - -WORKDIR /app - -RUN apk upgrade --no-cache libcrypto3 libssl3 - -COPY bun.lock package.json ./ -COPY apps/web/package.json ./apps/web/ - -RUN bun install --frozen-lockfile - -COPY apps/web ./apps/web - -RUN bun run --cwd apps/web build - -FROM nginx:1.31.0-alpine AS runner -ARG BUILD_VERSION=0.1.0 -ARG BUILD_REVISION=development -ARG BUILD_TIME=unknown - -RUN apk upgrade --no-cache libxml2 libcrypto3 libssl3 libexpat - -COPY --from=builder /app/apps/web/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -RUN short_revision="$(printf '%s' "$BUILD_REVISION" | cut -c1-12)" \ - && printf '{"service":"typetype","version":"%s","revision":"%s","shortRevision":"%s","buildTime":"%s"}\n' \ - "$BUILD_VERSION" "$BUILD_REVISION" "$short_revision" "$BUILD_TIME" \ - > /usr/share/nginx/html/version.json \ - && printf '{"service":"web","version":"%s","revision":"%s","shortRevision":"%s","buildTime":"%s"}\n' \ - "$BUILD_VERSION" "$BUILD_REVISION" "$short_revision" "$BUILD_TIME" \ - > /usr/share/nginx/html/version-web.json - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/Docs-TypeType b/Docs-TypeType new file mode 160000 index 00000000..a69d6f7d --- /dev/null +++ b/Docs-TypeType @@ -0,0 +1 @@ +Subproject commit a69d6f7d6c16a4d59d0693075457592a0040eecc diff --git a/README.md b/README.md index c251b37b..e5465247 100644 --- a/README.md +++ b/README.md @@ -1,384 +1,64 @@ -
+

TypeType -

+

-
+# TypeType -[MIT license](LICENSE) -[TypeType](https://github.com/Priveetee/TypeType) -[PipePipe](https://github.com/InfinityLoop1308/PipePipeExtractor) -[React](https://react.dev) +TypeType is a self-hosted web client for YouTube, with support for NicoNico and BiliBili. This repository is the central project repository: it contains the Docker Compose stack, installer, update and rollback tooling, release notes, and issue tracker. -
+> [!IMPORTANT] +> The TypeType repositories moved from `Priveetee` to the [`TypeType-Video`](https://github.com/TypeType-Video) organization. GitHub redirects existing repository links. New installations use the organization container image paths; the previous image paths remain available during the transition. -TypeType is a self-hostable video app for YouTube, NicoNico and BiliBili. +## Install -It is not only a web UI. This repository contains the TypeType web client and the deployment files for running the full stack: frontend, Kotlin API backend, PostgreSQL, Dragonfly, media proxying, token service, downloader service and Garage-backed download storage. +Docker Engine and Docker Compose v2 are required. -## Documentation - -Full documentation lives at **[priveetee.github.io/Docs-TypeType](https://priveetee.github.io/Docs-TypeType/)**: - -- [Self-hosting guide](https://priveetee.github.io/Docs-TypeType/self-hosting/introduction), set up and operate the stack, including a fully script-free Docker Compose setup. -- [User guide](https://priveetee.github.io/Docs-TypeType/guide/), everything the app can do. - -## Start Here - -Install and start the stack with one command: - -```sh -curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash -``` - -The installer creates `~/typetype-stack`, generates local downloader and YouTube remote login secrets, selects available ports when defaults are busy, starts the services, and bootstraps Garage. On `arm64`/`aarch64` hosts, including Raspberry Pi 4, it automatically adds the ARM64 Compose override that uses `redis:7-alpine` as the Redis-compatible cache service instead of Dragonfly. - -After install: - -```sh -cd ~/typetype-stack -docker compose ps -curl -fsS http://localhost:8080/health -``` - -Open the web app: - -```text -http://localhost:8082 -``` - -Default local endpoints: - -| Service | URL | -|---|---| -| Web app | `http://localhost:8082` | -| API backend | `http://localhost:8080` | -| Token service | `http://localhost:8081` | -| Garage S3 | `http://localhost:3900` | - -Download the stack files without starting Docker: - -```sh -curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash -s -- --download-only -``` - -Install and start only the beta stack automatically: - -```sh -curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/dev/scripts/install-stack.sh | bash -s -- --beta --yes -``` - -The beta installer creates `~/typetype-beta-stack`, downloads `docker-compose.dev.yml`, pulls only the beta images, starts only that Compose stack, and bootstraps Garage. - -Beta endpoints: - -| Service | URL | -|---|---| -| Web app | `http://localhost:18082` | -| API backend | `http://localhost:18080` | -| Token service | `http://localhost:18081` | -| Garage S3 | `http://localhost:3900` | - -Update only the beta stack: - -```sh -cd ~/typetype-beta-stack -docker compose -f docker-compose.dev.yml --env-file .env pull -docker compose -f docker-compose.dev.yml --env-file .env up -d --wait --wait-timeout 180 -docker compose -f docker-compose.dev.yml --env-file .env ps -``` - -On ARM64 hosts, include the override when running Compose manually: - -```sh -docker compose -f docker-compose.yml -f docker-compose.arm64.yml --env-file .env up -d -docker compose -f docker-compose.dev.yml -f docker-compose.arm64.yml --env-file .env up -d -``` - -## Screenshots - -![TypeType home](assets/screenshots/01-home-desktop.png) - -### Multi-Service Search - -![TypeType multi-service search](assets/gifs/multi-service-search.gif) - -| YouTube | NicoNico | BiliBili | -|---|---|---| -| ![YouTube search](assets/screenshots/02-youtube-search-desktop.png) | ![NicoNico search](assets/screenshots/05-niconico-search-desktop.png) | ![BiliBili search](assets/screenshots/07-bilibili-search-desktop.png) | - -### Playback - -![TypeType watch flow](assets/gifs/watch-flow.gif) - -| YouTube | NicoNico | BiliBili | -|---|---|---| -| ![YouTube watch page](assets/screenshots/04-youtube-watch-desktop.png) | ![NicoNico watch page](assets/screenshots/06-niconico-watch-desktop.png) | ![BiliBili watch page](assets/screenshots/08-bilibili-watch-desktop.png) | - -| Save to playlist | Download formats | NicoNico danmaku | -|---|---|---| -| ![Save to playlist](assets/screenshots/15-youtube-save-dropdown-desktop.png) | ![Download format picker](assets/screenshots/16-youtube-download-sheet-desktop.png) | ![NicoNico danmaku](assets/screenshots/17-niconico-danmaku-desktop.png) | - -### Library Flow - -![TypeType library flow](assets/gifs/library-flow.gif) - -| Playlists | History | Import | -|---|---|---| -| ![Playlists](assets/screenshots/09-library-playlists-desktop.png) | ![History](assets/screenshots/10-history-desktop.png) | ![Import](assets/screenshots/11-import-desktop.png) | - -| Channel | Settings | -|---|---| -| ![Channel page](assets/screenshots/03-youtube-channel-desktop.png) | ![Settings](assets/screenshots/12-settings-desktop.png) | - -Subscriptions: - -![Subscriptions](assets/screenshots/14-subscriptions-desktop.png) - -Import and settings flow: - -![TypeType import and settings flow](assets/gifs/import-settings-flow.gif) - -Mobile layout: - -![TypeType mobile flow](assets/gifs/mobile-flow.gif) - -| Home | Search | Watch | -|---|---|---| -| ![TypeType mobile home](assets/screenshots/13-home-mobile.png) | ![TypeType mobile search](assets/screenshots/18-mobile-search.png) | ![TypeType mobile watch](assets/screenshots/19-mobile-watch.png) | - -## What It Does - -- Plays YouTube, NicoNico and BiliBili videos from a self-hosted web app. -- Stores history, subscriptions, playlists, favorites, watch later, progress and settings on your own instance. -- Searches, loads trending feeds, shows comments and opens channel pages through the backend API. -- Proxies media when direct browser playback is unreliable or provider-specific headers are needed. -- Generates DASH manifests when separate audio/video streams are available. -- Imports YouTube Takeout data and PipePipe backup data. -- Runs download jobs through a separate downloader service. - -## What This Is Not - -- Not a hosted SaaS. -- Not a YouTube-only frontend clone. -- Not a fork of Piped, FreeTube, LibreTube, Invidious or NewPipe. -- Not a browser-side extractor. Extraction stays behind the HTTP API boundary. -- Not affiliated with YouTube, NicoNico, BiliBili or any upstream video platform. - -## Stack - -| Role | Project | -|---|---| -| Web client | React, TypeScript, Vite, TanStack Router, TanStack Query, Tailwind CSS | -| API backend | Kotlin, Ktor, PipePipeExtractor | -| User data | PostgreSQL | -| Extraction cache | Dragonfly | -| Media proxy | TypeType-Server | -| Token service | TypeType-Token | -| Downloader | TypeType-Downloader | -| Download storage | Garage S3 | -| Deployment | Docker Compose | - -## API Smoke Tests - -Run these after the stack is up. - -Health: - -```sh -curl -fsS http://localhost:8080/health +```bash +curl -fsSL https://raw.githubusercontent.com/TypeType-Video/TypeType/main/scripts/install-stack.sh | bash ``` -YouTube search: +The installer creates `~/typetype-stack`, prompts before starting the stack, generates installation-specific secrets, and preserves the existing `.env` file during updates. -```sh -curl -fsS "http://localhost:8080/search?q=lofi&service=0" -``` - -NicoNico suggestions: - -```sh -curl -fsS "http://localhost:8080/suggestions?query=miku&service=6" -``` - -BiliBili trending: - -```sh -curl -fsS "http://localhost:8080/trending?service=5" -``` - -YouTube stream extraction: - -```sh -curl -fsS "http://localhost:8080/streams?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ" -``` - -NicoNico stream extraction: - -```sh -curl -fsS "http://localhost:8080/streams?url=https%3A%2F%2Fwww.nicovideo.jp%2Fwatch%2Fsm9" -``` - -Create a guest session for protected user-data endpoints: - -```sh -curl -fsS -X POST http://localhost:8080/auth/guest -``` - -Use the returned token as a bearer token, or let Python extract it: - -```sh -TOKEN="$(curl -fsS -X POST http://localhost:8080/auth/guest | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')" -curl -fsS http://localhost:8080/settings -H "Authorization: Bearer ${TOKEN}" -``` - -Service IDs: - -| Service | ID | -|---|---:| -| YouTube | `0` | -| BiliBili | `5` | -| NicoNico | `6` | - -## Manual Install - -The installer is recommended. For a fully **script-free** Docker Compose setup (no bootstrap scripts), follow the [manual setup guide](https://priveetee.github.io/Docs-TypeType/self-hosting/docker-compose). If you want to run from a cloned repository with the helper scripts instead: - -```sh -git clone https://github.com/Priveetee/TypeType.git -cd TypeType -cp .env.example .env -./scripts/bootstrap-env.sh -./scripts/setup-stack.sh -``` - -Manual Docker Compose flow: - -```sh -cp .env.example .env -./scripts/bootstrap-env.sh -docker compose config -q -docker compose pull -./scripts/bootstrap-garage.sh -docker compose up -d --wait --wait-timeout 180 -docker compose ps -``` - -The script-free guide gives the equivalent one-time secret generation and Garage provisioning commands. Once Garage is provisioned, updates and verification only require Docker Compose and curl: - -```sh -docker compose config -q -docker compose pull -docker compose up -d --wait --wait-timeout 180 -docker compose ps -curl -fsS http://localhost:8080/health -curl -fsS http://localhost:8081/health -curl -fsS "http://localhost:8081/potoken?videoId=dQw4w9WgXcQ" -curl -fsS http://localhost:8082/api/downloader/health/deep -``` - -If you do the manual flow, edit `.env` before exposing the stack outside localhost. In particular, keep the generated downloader S3 access key, downloader S3 secret key, Garage RPC secret, YouTube remote login token, and YouTube session encryption key private. - -For a controlled update, set `TYPETYPE_WEB_IMAGE`, `TYPETYPE_SERVER_IMAGE`, `TYPETYPE_DOWNLOADER_IMAGE`, and `TYPETYPE_TOKEN_IMAGE` in `.env` to immutable `sha-` image tags. Keep the previous four values before changing them. Restoring those values and running `docker compose up -d --wait --wait-timeout 180` rolls the application services back without changing the data volumes. Beta uses the corresponding variables ending in `_BETA_IMAGE`. - -## Updating - -Update the whole stack: - -```sh -curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash -s -- --yes -cd ~/typetype-stack -docker compose ps -``` - -Running the installer again updates the Compose and configuration files, keeps the -existing `.env` and data volumes, pulls the new images, recreates changed services, -and provisions any newly added Garage resources. Existing configured ports are kept. - -For a script-free update, first replace `docker-compose.yml`, `nginx.conf`, and -`garage.toml` with their current release versions while keeping `.env`, then run the -manual update and Garage provisioning steps from the self-hosting guide. - -Update only the web client: - -```sh -cd ~/typetype-stack -docker compose pull typetype -docker compose up -d --force-recreate --no-deps typetype -``` - -## Local Development - -Install dependencies: - -```sh -bun install -``` - -Create the frontend environment file: - -```sh -cp apps/web/.env.example apps/web/.env -``` +For manual installation and operating instructions, use the documentation: -Set the API URL: +- [Self-hosting guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/introduction) +- [Manual Docker Compose setup](https://typetype-video.github.io/Docs-TypeType/self-hosting/docker-compose#manual-setup) +- [Update guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/maintenance) +- [Rollback guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/rollback) +- [User guide](https://typetype-video.github.io/Docs-TypeType/guide/) -```env -VITE_API_URL=http://localhost:8080 -``` +## Repositories -Run the dev server: +| Repository | Responsibility | License | +| --- | --- | --- | +| [TypeType](https://github.com/TypeType-Video/TypeType) | Stack, releases, coordination, and issues | MIT | +| [TypeType-Frontend](https://github.com/TypeType-Video/TypeType-Frontend) | React web client | MIT | +| [TypeType-Server](https://github.com/TypeType-Video/TypeType-Server) | Kotlin API and extraction backend | GPL-3.0 | +| [TypeType-Player](https://github.com/TypeType-Video/TypeType-Player) | MSE and SABR playback package | MIT | +| [TypeType-Token](https://github.com/TypeType-Video/TypeType-Token) | YouTube token and decoder service | MIT | +| [TypeType-Downloader](https://github.com/TypeType-Video/TypeType-Downloader) | Download jobs and artifacts | GPL-3.0-or-later | +| [Docs-TypeType](https://github.com/TypeType-Video/Docs-TypeType) | User and self-hosting documentation | MIT | -```sh -bun run dev -``` +Development changes land on each component's `dev` branch. Component images notify this repository with their exact digest. Component CI does not deploy beta or production instances. -Open: +The public component repositories are also available here as Git submodules. Clone the complete source tree with: -```text -http://localhost:5173 +```bash +git clone --recurse-submodules https://github.com/TypeType-Video/TypeType.git ``` -Checks: +## Privacy And Disclaimer -```sh -bun run check -bun run build -bun run knip -bun run sherif -``` +TypeType is designed to provide a private, self-hosted way to use supported media services. The project does not add telemetry or collect usage data. Instance operators control their own deployment, accounts, logs, storage, and network configuration. -## Related Repositories +TypeType and its contents are not affiliated with, funded, authorized, endorsed by, or associated with YouTube, Google LLC, NicoNico, BiliBili, or their affiliates. Trademarks, service marks, trade names, and other intellectual property belong to their respective owners. -- [TypeType](https://github.com/Priveetee/TypeType) contains the web client and deployment stack. -- [TypeType-Server](https://github.com/Priveetee/TypeType-Server) is the Kotlin/Ktor API backend. -- [TypeType-Downloader](https://github.com/Priveetee/TypeType-Downloader) handles download jobs and artifacts. -- [TypeType-Token](https://github.com/Priveetee/TypeType-Token) provides YouTube PO tokens. +TypeType is open source software built for learning and research purposes. -Clone the source repositories directly: +## Contributing -```sh -git clone https://github.com/Priveetee/TypeType.git -git clone https://github.com/Priveetee/TypeType-Server.git -git clone https://github.com/Priveetee/TypeType-Downloader.git -git clone https://github.com/Priveetee/TypeType-Token.git -``` - -## Notes - -TypeType is usable, but it is still young. Video providers change frequently, and provider-specific extraction, signed URLs, manifests, headers, range requests and cache TTLs can break over time. - -The clean boundary is HTTP. The web client talks to the API; extraction stays in TypeType-Server. - -## Acknowledgments - -TypeType is a clean rewrite, but its direction was shaped by existing open-source video clients and extractor projects. - -- [PipePipe](https://github.com/InfinityLoop1308/PipePipe) and [PipePipeExtractor](https://github.com/InfinityLoop1308/PipePipeExtractor) for multi-service extraction behavior. -- [Piped](https://github.com/TeamPiped/Piped-Frontend) for UX and API pattern references. -- [FreeTube](https://github.com/FreeTubeApp/FreeTube) for video player behavior references. +Use the [central issue tracker](https://github.com/TypeType-Video/TypeType/issues) for bug reports and feature requests. Pull requests belong in the repository that owns the affected component. ## License -This repository is MIT licensed. See [LICENSE](LICENSE). - -TypeType-Server is GPLv3 because it uses PipePipeExtractor. +The files in this repository are licensed under the [MIT License](LICENSE). Components keep their own licenses; in particular, TypeType-Server and other GPL components remain under GPL-3.0. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6ca01fd4..5b128d25 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,6 +13,6 @@ TypeType runtime behavior, API contracts and self-hosting configuration are unch ## Updating -Follow the [update guide](https://priveetee.github.io/Docs-TypeType/self-hosting/maintenance). +Follow the [update guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/maintenance). -If necessary, follow the [rollback guide](https://priveetee.github.io/Docs-TypeType/self-hosting/rollback). +If necessary, follow the [rollback guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/rollback). diff --git a/TypeType-Downloader b/TypeType-Downloader new file mode 160000 index 00000000..f82abf39 --- /dev/null +++ b/TypeType-Downloader @@ -0,0 +1 @@ +Subproject commit f82abf3947911aad67f57e0a0bd52e114c2c8375 diff --git a/TypeType-Frontend b/TypeType-Frontend new file mode 160000 index 00000000..3e9f3cb7 --- /dev/null +++ b/TypeType-Frontend @@ -0,0 +1 @@ +Subproject commit 3e9f3cb70ea376c2f4eafffb210678964e37f75a diff --git a/TypeType-Player b/TypeType-Player new file mode 160000 index 00000000..5b8fdc84 --- /dev/null +++ b/TypeType-Player @@ -0,0 +1 @@ +Subproject commit 5b8fdc84c552bc85b5c2804bf58b32f46b333437 diff --git a/TypeType-Server b/TypeType-Server new file mode 160000 index 00000000..232fa28d --- /dev/null +++ b/TypeType-Server @@ -0,0 +1 @@ +Subproject commit 232fa28d2f88d069328c299e5074c4142bb8c165 diff --git a/TypeType-Token b/TypeType-Token new file mode 160000 index 00000000..6795a612 --- /dev/null +++ b/TypeType-Token @@ -0,0 +1 @@ +Subproject commit 6795a612822f9b18a8f11e789126382636cfea20 diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index d2911dca..00000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_URL=http://localhost:8080 -VITE_DEV_PROXY_TARGET=http://localhost:8080 diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index 2bd18039..00000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -.env - -# 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/apps/web/index.html b/apps/web/index.html deleted file mode 100644 index c0fcd1c3..00000000 --- a/apps/web/index.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - TypeType - - - - -
- -
- -
- - - diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 03a72cb1..00000000 --- a/apps/web/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@typetype/web", - "private": true, - "version": "1.0.3", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "test": "bun test", - "test:coverage": "bun test --coverage --coverage-reporter=lcov" - }, - "dependencies": { - "@tanstack/react-query": "^5.101.2", - "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.31", - "@vidstack/react": "1.12.13", - "dashjs": "^5.2.0", - "hls.js": "1.6.16", - "lucide-react": "^1.24.0", - "media-icons": "1.1.5", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "simple-icons": "^16.25.0", - "zustand": "^5.0.14" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.3.2", - "@tanstack/router-plugin": "^1.168.19", - "@types/node": "^26.1.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "^4.3.2", - "typescript": "~6.0.3", - "vite": "^8.1.4" - } -} diff --git a/apps/web/public/app.webmanifest b/apps/web/public/app.webmanifest deleted file mode 100644 index a5982ae9..00000000 --- a/apps/web/public/app.webmanifest +++ /dev/null @@ -1,22 +0,0 @@ -{ - "id": "/", - "name": "TypeType", - "short_name": "TypeType", - "start_url": "/", - "scope": "/", - "display": "standalone", - "background_color": "#09090b", - "theme_color": "#09090b", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png deleted file mode 100644 index b3002740..00000000 Binary files a/apps/web/public/apple-touch-icon.png and /dev/null differ diff --git a/apps/web/public/downloader-waiting.gif b/apps/web/public/downloader-waiting.gif deleted file mode 100644 index 46062571..00000000 Binary files a/apps/web/public/downloader-waiting.gif and /dev/null differ diff --git a/apps/web/public/error-cat.gif b/apps/web/public/error-cat.gif deleted file mode 100644 index b9f31ce5..00000000 Binary files a/apps/web/public/error-cat.gif and /dev/null differ diff --git a/apps/web/public/family-list-blocked.gif b/apps/web/public/family-list-blocked.gif deleted file mode 100644 index fdc0fe91..00000000 Binary files a/apps/web/public/family-list-blocked.gif and /dev/null differ diff --git a/apps/web/public/guest-disabled-bird.gif b/apps/web/public/guest-disabled-bird.gif deleted file mode 100644 index ef1e69ca..00000000 Binary files a/apps/web/public/guest-disabled-bird.gif and /dev/null differ diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png deleted file mode 100644 index 570e0186..00000000 Binary files a/apps/web/public/icon-192.png and /dev/null differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png deleted file mode 100644 index bb04b250..00000000 Binary files a/apps/web/public/icon-512.png and /dev/null differ diff --git a/apps/web/public/import-cooking-chef.gif b/apps/web/public/import-cooking-chef.gif deleted file mode 100644 index 8df21e25..00000000 Binary files a/apps/web/public/import-cooking-chef.gif and /dev/null differ diff --git a/apps/web/public/import-cooking-chef.webm b/apps/web/public/import-cooking-chef.webm deleted file mode 100644 index dc206595..00000000 Binary files a/apps/web/public/import-cooking-chef.webm and /dev/null differ diff --git a/apps/web/public/import-dudu-cooking.gif b/apps/web/public/import-dudu-cooking.gif deleted file mode 100644 index 7d9d873e..00000000 Binary files a/apps/web/public/import-dudu-cooking.gif and /dev/null differ diff --git a/apps/web/public/import-dudu-cooking.webm b/apps/web/public/import-dudu-cooking.webm deleted file mode 100644 index c8b6dcde..00000000 Binary files a/apps/web/public/import-dudu-cooking.webm and /dev/null differ diff --git a/apps/web/public/loader.gif b/apps/web/public/loader.gif deleted file mode 100644 index 23873b25..00000000 Binary files a/apps/web/public/loader.gif and /dev/null differ diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg deleted file mode 100644 index 2d412a28..00000000 --- a/apps/web/public/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/web/public/member-only-source.gif b/apps/web/public/member-only-source.gif deleted file mode 100644 index 7bee0d9f..00000000 Binary files a/apps/web/public/member-only-source.gif and /dev/null differ diff --git a/apps/web/public/member-only.gif b/apps/web/public/member-only.gif deleted file mode 100644 index 7bee0d9f..00000000 Binary files a/apps/web/public/member-only.gif and /dev/null differ diff --git a/apps/web/public/pipepipe-logo.png b/apps/web/public/pipepipe-logo.png deleted file mode 100644 index a783bfec..00000000 Binary files a/apps/web/public/pipepipe-logo.png and /dev/null differ diff --git a/apps/web/public/pixel/bird-0.png b/apps/web/public/pixel/bird-0.png deleted file mode 100644 index a6e7ae56..00000000 Binary files a/apps/web/public/pixel/bird-0.png and /dev/null differ diff --git a/apps/web/public/pixel/bird-1.png b/apps/web/public/pixel/bird-1.png deleted file mode 100644 index 2684a827..00000000 Binary files a/apps/web/public/pixel/bird-1.png and /dev/null differ diff --git a/apps/web/public/pixel/bird-2.png b/apps/web/public/pixel/bird-2.png deleted file mode 100644 index 0c8c935f..00000000 Binary files a/apps/web/public/pixel/bird-2.png and /dev/null differ diff --git a/apps/web/public/pixel/bird-3.png b/apps/web/public/pixel/bird-3.png deleted file mode 100644 index bd0e4890..00000000 Binary files a/apps/web/public/pixel/bird-3.png and /dev/null differ diff --git a/apps/web/public/pixel/cat-0.png b/apps/web/public/pixel/cat-0.png deleted file mode 100644 index eeb2ffd5..00000000 Binary files a/apps/web/public/pixel/cat-0.png and /dev/null differ diff --git a/apps/web/public/pixel/cat-1.png b/apps/web/public/pixel/cat-1.png deleted file mode 100644 index f2e190aa..00000000 Binary files a/apps/web/public/pixel/cat-1.png and /dev/null differ diff --git a/apps/web/public/pixel/cat-2.png b/apps/web/public/pixel/cat-2.png deleted file mode 100644 index cbf28699..00000000 Binary files a/apps/web/public/pixel/cat-2.png and /dev/null differ diff --git a/apps/web/public/pixel/cat-3.png b/apps/web/public/pixel/cat-3.png deleted file mode 100644 index 9b9be398..00000000 Binary files a/apps/web/public/pixel/cat-3.png and /dev/null differ diff --git a/apps/web/public/pixel/cat-4.png b/apps/web/public/pixel/cat-4.png deleted file mode 100644 index f725fd53..00000000 Binary files a/apps/web/public/pixel/cat-4.png and /dev/null differ diff --git a/apps/web/public/pixel/cloud-0.png b/apps/web/public/pixel/cloud-0.png deleted file mode 100644 index 0361a50a..00000000 Binary files a/apps/web/public/pixel/cloud-0.png and /dev/null differ diff --git a/apps/web/public/pixel/cloud-1.png b/apps/web/public/pixel/cloud-1.png deleted file mode 100644 index 143b5450..00000000 Binary files a/apps/web/public/pixel/cloud-1.png and /dev/null differ diff --git a/apps/web/public/pixel/cloud-3.png b/apps/web/public/pixel/cloud-3.png deleted file mode 100644 index 24cb4b57..00000000 Binary files a/apps/web/public/pixel/cloud-3.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-0.png b/apps/web/public/pixel/dog-carry-0.png deleted file mode 100644 index 0f9393f6..00000000 Binary files a/apps/web/public/pixel/dog-carry-0.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-1.png b/apps/web/public/pixel/dog-carry-1.png deleted file mode 100644 index ccea0f12..00000000 Binary files a/apps/web/public/pixel/dog-carry-1.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-2.png b/apps/web/public/pixel/dog-carry-2.png deleted file mode 100644 index f0522021..00000000 Binary files a/apps/web/public/pixel/dog-carry-2.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-3.png b/apps/web/public/pixel/dog-carry-3.png deleted file mode 100644 index dd72d437..00000000 Binary files a/apps/web/public/pixel/dog-carry-3.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-4.png b/apps/web/public/pixel/dog-carry-4.png deleted file mode 100644 index 4fa445c1..00000000 Binary files a/apps/web/public/pixel/dog-carry-4.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-5.png b/apps/web/public/pixel/dog-carry-5.png deleted file mode 100644 index 3b0b3250..00000000 Binary files a/apps/web/public/pixel/dog-carry-5.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-6.png b/apps/web/public/pixel/dog-carry-6.png deleted file mode 100644 index 8197eb83..00000000 Binary files a/apps/web/public/pixel/dog-carry-6.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-7.png b/apps/web/public/pixel/dog-carry-7.png deleted file mode 100644 index 23da0154..00000000 Binary files a/apps/web/public/pixel/dog-carry-7.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-8.png b/apps/web/public/pixel/dog-carry-8.png deleted file mode 100644 index c798ecad..00000000 Binary files a/apps/web/public/pixel/dog-carry-8.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-carry-9.png b/apps/web/public/pixel/dog-carry-9.png deleted file mode 100644 index de2c20c6..00000000 Binary files a/apps/web/public/pixel/dog-carry-9.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-idle.png b/apps/web/public/pixel/dog-idle.png deleted file mode 100644 index 772ae360..00000000 Binary files a/apps/web/public/pixel/dog-idle.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-rest-0.png b/apps/web/public/pixel/dog-rest-0.png deleted file mode 100644 index be187ed5..00000000 Binary files a/apps/web/public/pixel/dog-rest-0.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-rest-1.png b/apps/web/public/pixel/dog-rest-1.png deleted file mode 100644 index f4e043fe..00000000 Binary files a/apps/web/public/pixel/dog-rest-1.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-rest-2.png b/apps/web/public/pixel/dog-rest-2.png deleted file mode 100644 index 4b06267e..00000000 Binary files a/apps/web/public/pixel/dog-rest-2.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-run-0.png b/apps/web/public/pixel/dog-run-0.png deleted file mode 100644 index cdb07178..00000000 Binary files a/apps/web/public/pixel/dog-run-0.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-run-1.png b/apps/web/public/pixel/dog-run-1.png deleted file mode 100644 index 5d0e213b..00000000 Binary files a/apps/web/public/pixel/dog-run-1.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-run-2.png b/apps/web/public/pixel/dog-run-2.png deleted file mode 100644 index f7844b2a..00000000 Binary files a/apps/web/public/pixel/dog-run-2.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-run-3.png b/apps/web/public/pixel/dog-run-3.png deleted file mode 100644 index 7b520f7a..00000000 Binary files a/apps/web/public/pixel/dog-run-3.png and /dev/null differ diff --git a/apps/web/public/pixel/dog-run-4.png b/apps/web/public/pixel/dog-run-4.png deleted file mode 100644 index 9e5df25e..00000000 Binary files a/apps/web/public/pixel/dog-run-4.png and /dev/null differ diff --git a/apps/web/public/pixel/table.svg b/apps/web/public/pixel/table.svg deleted file mode 100644 index 5250015c..00000000 --- a/apps/web/public/pixel/table.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/pixel/tv-0.png b/apps/web/public/pixel/tv-0.png deleted file mode 100644 index a0b6ff7c..00000000 Binary files a/apps/web/public/pixel/tv-0.png and /dev/null differ diff --git a/apps/web/public/pixel/tv-1.png b/apps/web/public/pixel/tv-1.png deleted file mode 100644 index dbe7d890..00000000 Binary files a/apps/web/public/pixel/tv-1.png and /dev/null differ diff --git a/apps/web/public/pixel/tv-2.png b/apps/web/public/pixel/tv-2.png deleted file mode 100644 index 09aa407e..00000000 Binary files a/apps/web/public/pixel/tv-2.png and /dev/null differ diff --git a/apps/web/public/pixel/tv-3.png b/apps/web/public/pixel/tv-3.png deleted file mode 100644 index 2d6a176a..00000000 Binary files a/apps/web/public/pixel/tv-3.png and /dev/null differ diff --git a/apps/web/public/pixel/tv-4.png b/apps/web/public/pixel/tv-4.png deleted file mode 100644 index 50f76a44..00000000 Binary files a/apps/web/public/pixel/tv-4.png and /dev/null differ diff --git a/apps/web/public/pixel/waterfall-0.png b/apps/web/public/pixel/waterfall-0.png deleted file mode 100644 index d49f5f3e..00000000 Binary files a/apps/web/public/pixel/waterfall-0.png and /dev/null differ diff --git a/apps/web/public/pixel/waterfall-1.png b/apps/web/public/pixel/waterfall-1.png deleted file mode 100644 index c0c2d261..00000000 Binary files a/apps/web/public/pixel/waterfall-1.png and /dev/null differ diff --git a/apps/web/public/pixel/waterfall-2.png b/apps/web/public/pixel/waterfall-2.png deleted file mode 100644 index 1f1ae661..00000000 Binary files a/apps/web/public/pixel/waterfall-2.png and /dev/null differ diff --git a/apps/web/public/pixel/waterfall-3.png b/apps/web/public/pixel/waterfall-3.png deleted file mode 100644 index c7a4a14b..00000000 Binary files a/apps/web/public/pixel/waterfall-3.png and /dev/null differ diff --git a/apps/web/public/sad-sigh.gif b/apps/web/public/sad-sigh.gif deleted file mode 100644 index b919076f..00000000 Binary files a/apps/web/public/sad-sigh.gif and /dev/null differ diff --git a/apps/web/src/components/account-identity-settings.tsx b/apps/web/src/components/account-identity-settings.tsx deleted file mode 100644 index d52005c9..00000000 --- a/apps/web/src/components/account-identity-settings.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from "react"; -import { useAccountIdentity } from "../hooks/use-account-identity"; - -type Props = { - enabled: boolean; - onMessage: (message: string) => void; -}; - -export function AccountIdentitySettings({ enabled, onMessage }: Props) { - const { query, update } = useAccountIdentity(enabled); - const [email, setEmail] = useState(""); - const [name, setName] = useState(""); - const [password, setPassword] = useState(""); - - useEffect(() => { - if (!query.data) return; - setEmail(query.data.email); - setName(query.data.name); - }, [query.data]); - - if (!enabled || query.isPending) return null; - const managed = query.data?.managedByOidc ?? false; - const dirty = - email.trim().toLowerCase() !== query.data?.email || name.trim() !== query.data?.name; - - return ( -
-
-

Account identity

-

- {managed ? "Managed by your identity provider" : "Used for sign-in and account display"} -

-
-
- - -
- {!managed && ( -
- - -
- )} -
- ); -} diff --git a/apps/web/src/components/admin-allow-list-channel-list.tsx b/apps/web/src/components/admin-allow-list-channel-list.tsx deleted file mode 100644 index 627385d7..00000000 --- a/apps/web/src/components/admin-allow-list-channel-list.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { channelRoutePath } from "../lib/channel-route-url"; -import type { AllowedChannelItem } from "../types/user"; -import { ChannelAvatar } from "./channel-avatar"; -import { ChannelRouteLink } from "./channel-route-link"; - -function XIcon() { - return ( - - ); -} - -type Props = { - title?: string; - channels: AllowedChannelItem[]; - onRemove?: (url: string) => void; -}; - -export function AdminAllowListChannelList({ - title = "Allowed channels", - channels, - onRemove, -}: Props) { - return ( -
-
-
-

{title}

-

- Channels available when allow-list mode is enabled. -

-
- - {channels.length} {channels.length === 1 ? "channel" : "channels"} - -
- {channels.length === 0 ? ( -

No channels added.

- ) : ( -
- {channels.map((item) => { - const label = item.name ?? item.url; - const typeTypeUrl = channelRoutePath(item.url); - return ( -
- -
- - {label} - - - {typeTypeUrl} - -
- {onRemove && ( - - )} -
- ); - })} -
- )} -
- ); -} diff --git a/apps/web/src/components/admin-allow-list-form.tsx b/apps/web/src/components/admin-allow-list-form.tsx deleted file mode 100644 index 3937ce2d..00000000 --- a/apps/web/src/components/admin-allow-list-form.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { useDebouncedValue } from "../hooks/use-debounced-value"; -import { fetchSearch } from "../lib/api-discovery"; -import { normalizeChannelUrl } from "../lib/channel-url"; -import { formatSubscribers } from "../lib/format"; -import { proxyImage } from "../lib/proxy"; -import type { ChannelResultItem } from "../types/api"; -import { ChannelAvatar } from "./channel-avatar"; -import { ChannelRouteLink } from "./channel-route-link"; - -function isTrusted(channel: ChannelResultItem, trustedUrls: Set): boolean { - return trustedUrls.has(normalizeChannelUrl(channel.url)); -} - -type Props = { - title: string; - description: string; - trustedUrls: string[]; - pending: boolean; - onAdd: (channel: ChannelResultItem) => void; -}; - -export function AdminAllowListForm({ title, description, trustedUrls, pending, onAdd }: Props) { - const [term, setTerm] = useState(""); - const debounced = useDebouncedValue(term.trim(), 300); - const trusted = new Set(trustedUrls.map(normalizeChannelUrl)); - const search = useQuery({ - queryKey: ["admin-allow-list-channel-search", debounced], - queryFn: () => fetchSearch(debounced, 0), - enabled: debounced.length >= 2, - staleTime: 60 * 1000, - }); - const channels = search.data?.channels ?? []; - - return ( -
-
-

{title}

-

{description}

-
- setTerm(event.target.value)} - placeholder="Channel name or @handle" - className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" - /> -
- {debounced.length < 2 ? ( -
- Type at least two characters to search channels. -
- ) : search.isLoading ? ( -
Searching channels...
- ) : channels.length === 0 ? ( -
- No channels found. Try the exact channel name or handle. -
- ) : ( -
- {channels.slice(0, 8).map((channel) => { - const alreadyAdded = isTrusted(channel, trusted); - return ( -
- -
- - {channel.name} - -

- {formatSubscribers(channel.subscriberCount)} -

-
- -
- ); - })} -
- )} -
-
- ); -} diff --git a/apps/web/src/components/admin-allow-list-playlist-list.tsx b/apps/web/src/components/admin-allow-list-playlist-list.tsx deleted file mode 100644 index 8f0abd46..00000000 --- a/apps/web/src/components/admin-allow-list-playlist-list.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { proxyImage } from "../lib/proxy"; -import type { AllowedPlaylistItem } from "../types/allow-list"; - -type Props = { - title: string; - playlists: AllowedPlaylistItem[]; - onRemove?: (url: string) => void; -}; - -function playlistPath(url: string): string { - const params = new URLSearchParams({ url }); - return `/playlist?${params.toString()}`; -} - -function XIcon() { - return ( - - ); -} - -export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) { - return ( -
-
-

{title}

- - {playlists.length} {playlists.length === 1 ? "playlist" : "playlists"} - -
- {playlists.length === 0 ? ( -

No playlists added.

- ) : ( -
- {playlists.map((playlist) => { - const label = playlist.title ?? playlist.url; - return ( -
- -
- - {label} - - - {playlistPath(playlist.url)} - -
- {onRemove && ( - - )} -
- ); - })} -
- )} -
- ); -} diff --git a/apps/web/src/components/admin-allow-list-playlist-search.tsx b/apps/web/src/components/admin-allow-list-playlist-search.tsx deleted file mode 100644 index cc76f26f..00000000 --- a/apps/web/src/components/admin-allow-list-playlist-search.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { useState } from "react"; -import { useDebouncedValue } from "../hooks/use-debounced-value"; -import { fetchSearch } from "../lib/api-discovery"; -import { proxyImage } from "../lib/proxy"; -import type { AllowPlaylistInput } from "../types/allow-list"; -import type { PublicPlaylistInfo } from "../types/playlist"; - -type Props = { - title: string; - description: string; - addedUrls: string[]; - pending: boolean; - onAdd: (playlist: AllowPlaylistInput) => void; -}; - -function playlistPath(url: string): string { - const params = new URLSearchParams({ url }); - return `/playlist?${params.toString()}`; -} - -function playlistSourceUrl(input: string): string { - const trimmed = input.trim(); - if (trimmed.length === 0) return ""; - try { - const parsed = new URL(trimmed, "http://typetype.local"); - const sourceUrl = parsed.pathname === "/playlist" ? parsed.searchParams.get("url") : null; - return sourceUrl?.trim() || trimmed; - } catch { - return trimmed; - } -} - -export function AdminAllowListPlaylistSearch({ - title, - description, - addedUrls, - pending, - onAdd, -}: Props) { - const [term, setTerm] = useState(""); - const [url, setUrl] = useState(""); - const debounced = useDebouncedValue(term.trim(), 300); - const added = new Set(addedUrls); - const search = useQuery({ - queryKey: ["admin-allow-list-playlist-search", debounced], - queryFn: () => fetchSearch(debounced, 0), - enabled: debounced.length >= 2, - staleTime: 60 * 1000, - }); - const playlists = search.data?.playlists ?? []; - const playlistUrl = playlistSourceUrl(url); - const urlAlreadyAdded = added.has(playlistUrl); - - function addUrl() { - if (playlistUrl.length === 0 || urlAlreadyAdded || pending) return; - onAdd({ url: playlistUrl, title: null, thumbnailUrl: null, uploaderName: null }); - setUrl(""); - } - - function addPlaylist(playlist: PublicPlaylistInfo) { - onAdd({ - url: playlist.url, - title: playlist.title, - thumbnailUrl: playlist.thumbnailUrl, - uploaderName: playlist.uploaderName, - }); - } - - return ( -
-
-

{title}

-

{description}

-
-
- setUrl(event.target.value)} - placeholder="Playlist URL" - className="h-10 flex-1 border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" - /> - -
- setTerm(event.target.value)} - placeholder="Playlist name" - className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" - /> -
- {debounced.length < 2 ? ( -
Type at least two characters.
- ) : search.isLoading ? ( -
Searching playlists...
- ) : playlists.length === 0 ? ( -
No playlists found.
- ) : ( -
- {playlists.slice(0, 8).map((playlist) => { - const alreadyAdded = added.has(playlist.url); - return ( -
- -
- - {playlist.title} - -

{playlistPath(playlist.url)}

-
- -
- ); - })} -
- )} -
-
- ); -} diff --git a/apps/web/src/components/admin-allow-list-section.tsx b/apps/web/src/components/admin-allow-list-section.tsx deleted file mode 100644 index ed056829..00000000 --- a/apps/web/src/components/admin-allow-list-section.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { - useAdminAllowedPlaylists, - useAdminAllowListMutations, -} from "../hooks/use-admin-granular-allow-list"; -import { useAdminSettings } from "../hooks/use-admin-settings"; -import { useAllowedChannels } from "../hooks/use-allowed-channels"; -import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; -import { AdminAllowListForm } from "./admin-allow-list-form"; -import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; -import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; -import { AdminAllowListUsers } from "./admin-allow-list-users"; - -const MODE_BUTTON = - "h-8 border border-border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60"; - -type Props = { - enabled: boolean; - onToast: (message: string) => void; -}; - -export function AdminAllowListSection({ enabled, onToast }: Props) { - const adminSettings = useAdminSettings(enabled); - const { query, add, remove } = useAllowedChannels(); - const globalPlaylists = useAdminAllowedPlaylists(enabled); - const mutations = useAdminAllowListMutations(null); - const settings = adminSettings.query.data; - const channels = (query.data ?? []).filter((item) => item.global === true); - const playlists = globalPlaylists.data ?? []; - const instanceRestricted = settings?.accessMode === "allow_list"; - - function setMode(accessMode: "unrestricted" | "allow_list") { - if (!settings) return; - adminSettings.update.mutate( - { ...settings, accessMode }, - { - onSuccess: () => onToast("Allow list mode updated"), - onError: (error) => - onToast(error instanceof Error ? error.message : "Unable to update mode"), - }, - ); - } - - if (adminSettings.query.isPending) { - return ( -
- Loading allow list... -
- ); - } - - if (!settings || adminSettings.query.isError) { - return ( -
- Unable to load allow list settings. -
- ); - } - - return ( -
-
-
-
-

Entire instance

-

- Restrict everyone to the admin allow list, including guests. -

-
-
- - -
-
-
- - item.url)} - pending={add.isPending} - onAdd={(channel) => - add.mutate({ - url: channel.url, - name: channel.name, - thumbnailUrl: channel.thumbnailUrl, - global: true, - }) - } - /> - - item.url)} - pending={mutations.addGlobalPlaylist.isPending} - onAdd={(playlist) => mutations.addGlobalPlaylist.mutate(playlist)} - /> - -
- ); -} diff --git a/apps/web/src/components/admin-allow-list-user-detail.tsx b/apps/web/src/components/admin-allow-list-user-detail.tsx deleted file mode 100644 index 65928e28..00000000 --- a/apps/web/src/components/admin-allow-list-user-detail.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - useAdminAllowListMutations, - useAdminUserAllowList, -} from "../hooks/use-admin-granular-allow-list"; -import type { AdminAllowListUser } from "../types/allow-list"; -import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; -import { AdminAllowListForm } from "./admin-allow-list-form"; -import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; -import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; -import { AdminUserAvatar } from "./admin-user-avatar"; - -type Props = { - user: AdminAllowListUser; - instanceRestricted: boolean; - onToast: (message: string) => void; -}; - -function accessState(user: AdminAllowListUser, instanceRestricted: boolean) { - if (user.accessMode === "allow_list") { - return { - label: "User-specific allow-list", - action: instanceRestricted ? "Set unrestricted override" : "Unrestrict user", - nextMode: "unrestricted" as const, - toast: instanceRestricted ? "Unrestricted override set" : "User unrestricted", - active: true, - }; - } - if (instanceRestricted && user.adminManagedAccessMode) { - return { - label: "Admin unrestricted override", - action: "Restrict user", - nextMode: "allow_list" as const, - toast: "User restricted", - active: true, - }; - } - if (instanceRestricted) { - return { - label: "Restricted by entire instance", - action: "Set unrestricted override", - nextMode: "unrestricted" as const, - toast: "Unrestricted override set", - active: false, - }; - } - return { - label: "Unrestricted", - action: "Restrict user", - nextMode: "allow_list" as const, - toast: "User restricted", - active: false, - }; -} - -export function AdminAllowListUserDetail({ user, instanceRestricted, onToast }: Props) { - const detail = useAdminUserAllowList(user.id); - const mutations = useAdminAllowListMutations(user.id); - const data = detail.data; - const selected = data?.user - ? { - ...user, - ...data.user, - adminManagedAccessMode: data.user.adminManagedAccessMode ?? user.adminManagedAccessMode, - avatarUrl: data.user.avatarUrl ?? user.avatarUrl, - avatarType: data.user.avatarType ?? user.avatarType, - avatarCode: data.user.avatarCode ?? user.avatarCode, - } - : user; - const state = accessState(selected, instanceRestricted); - - function toggleMode() { - mutations.userMode.mutate( - { id: selected.id, accessMode: state.nextMode }, - { - onSuccess: () => onToast(state.toast), - onError: (error) => - onToast(error instanceof Error ? error.message : "Unable to update user"), - }, - ); - } - - if (detail.isLoading || !data) { - return ( -
- Loading user allow list... -
- ); - } - - return ( -
-
-
-
- -
-

- {selected.name || selected.email} -

-

{selected.email}

-

{state.label}

-
-
- -
-
- - - - - item.url)} - pending={mutations.addUserChannel.isPending} - onAdd={(channel) => mutations.addUserChannel.mutate({ id: selected.id, channel })} - /> - mutations.removeUserChannel.mutate({ id: selected.id, url })} - /> - - item.url)} - pending={mutations.addUserPlaylist.isPending} - onAdd={(playlist) => mutations.addUserPlaylist.mutate({ id: selected.id, playlist })} - /> - mutations.removeUserPlaylist.mutate({ id: selected.id, url })} - /> -
- ); -} diff --git a/apps/web/src/components/admin-allow-list-users.tsx b/apps/web/src/components/admin-allow-list-users.tsx deleted file mode 100644 index c57d52b1..00000000 --- a/apps/web/src/components/admin-allow-list-users.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from "react"; -import { useAdminAllowListUsers, useAdminUserSearch } from "../hooks/use-admin-granular-allow-list"; -import type { AdminAllowListUser } from "../types/allow-list"; -import { AdminAllowListUserDetail } from "./admin-allow-list-user-detail"; -import { AdminUserAvatar } from "./admin-user-avatar"; - -type Props = { - enabled: boolean; - instanceRestricted: boolean; - onToast: (message: string) => void; -}; - -function avatarUser(user: AdminAllowListUser) { - return { - ...user, - role: "user" as const, - publicUsername: null, - bio: null, - avatarUrl: user.avatarUrl ?? null, - avatarType: user.avatarType ?? null, - avatarCode: user.avatarCode ?? null, - suspended: false, - verified: false, - createdAt: 0, - }; -} - -function accessLabel(user: AdminAllowListUser, instanceRestricted: boolean): string { - if (user.accessMode === "allow_list") return "User restricted"; - if (!instanceRestricted) return "Unrestricted"; - return user.adminManagedAccessMode ? "Admin override" : "Instance restricted"; -} - -export function AdminAllowListUsers({ enabled, instanceRestricted, onToast }: Props) { - const [search, setSearch] = useState(""); - const [selected, setSelected] = useState(null); - const searching = search.trim().length >= 2; - const managedQuery = useAdminAllowListUsers(enabled && !searching); - const searchQuery = useAdminUserSearch(search, enabled && searching); - const managedUsers = managedQuery.data?.pages.flatMap((page) => page.items) ?? []; - const visibleUsers = searching ? (searchQuery.data ?? []) : managedUsers; - const loading = searching ? searchQuery.isLoading : managedQuery.isLoading; - - return ( -
-
-

Specific users

-

- Users with admin-managed access appear here. Search to configure another account. -

-
- setSearch(event.target.value)} - placeholder="Search by email or name" - className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" - /> -
- {loading ? ( -
Searching users...
- ) : visibleUsers.length === 0 ? ( -
- {searching ? "No users found." : "No users are managed by allow-list rules yet."} -
- ) : ( -
- {visibleUsers.map((user) => ( - - ))} -
- )} -
- {!searching && managedQuery.hasNextPage && ( - - )} - {selected && ( -
- -
- )} -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-detail.tsx b/apps/web/src/components/admin-bug-report-detail.tsx deleted file mode 100644 index 3ad8aaa8..00000000 --- a/apps/web/src/components/admin-bug-report-detail.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { BugReportDetail, BugReportStatus } from "../types/bug-report"; -import { AdminBugReportDiagnostics } from "./admin-bug-report-diagnostics"; -import { AdminBugReportGitHubAction } from "./admin-bug-report-github-action"; -import { AdminBugReportOverview } from "./admin-bug-report-overview"; -import { AdminBugReportStatusControl } from "./admin-bug-report-status-control"; - -type Props = { - report: BugReportDetail; - busy: boolean; - isAdmin: boolean; - onStatusChange: (status: BugReportStatus) => void; - onCreateIssue: () => void; -}; - -export function AdminBugReportDetailPanel({ - report, - busy, - isAdmin, - onStatusChange, - onCreateIssue, -}: Props) { - return ( -
- - - - -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-diagnostics-details.tsx b/apps/web/src/components/admin-bug-report-diagnostics-details.tsx deleted file mode 100644 index df7d9c04..00000000 --- a/apps/web/src/components/admin-bug-report-diagnostics-details.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { formatTimestamp } from "../lib/bug-report-utils"; -import type { BugReportDetail } from "../types/bug-report"; - -type Props = { - report: BugReportDetail; -}; - -export function AdminBugReportDiagnosticsDetails({ report }: Props) { - const crashLogs = report.context.crashLogs; - const apiErrors = report.context.apiErrors; - - return ( - <> - {report.context.playerState && ( -
- Player state -
-            {JSON.stringify(report.context.playerState, null, 2)}
-          
-
- )} - - {apiErrors.length > 0 && ( -
- API errors -
- {apiErrors.slice(0, 8).map((error) => ( -
-

{error.endpoint}

-

- {error.status} · {error.code ?? "unknown"} · {formatTimestamp(error.timestamp)} -

-

{error.message}

-
- ))} -
-
- )} - - {crashLogs.length > 0 && ( -
- Crash logs -
- {crashLogs.slice(0, 5).map((entry) => ( -
-

{entry.message}

-

{formatTimestamp(entry.timestamp)}

-
- ))} -
-
- )} - - ); -} diff --git a/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx b/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx deleted file mode 100644 index 1fe62525..00000000 --- a/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { formatTimestamp } from "../lib/bug-report-utils"; -import type { BugReportDetail } from "../types/bug-report"; - -type Props = { - report: BugReportDetail; -}; - -function shorten(value: string, max: number): string { - if (value.length <= max) return value; - return `${value.slice(0, max)}...`; -} - -export function AdminBugReportDiagnosticsMeta({ report }: Props) { - return ( -
-

Route: {report.context.route || "unknown"}

-

Language: {report.context.browserLanguage || "unknown"}

-

- User agent: {shorten(report.context.userAgent || "unknown", 110)} -

-

- Crash logs: {report.context.crashLogs.length} · API errors:{" "} - {report.context.apiErrors.length} -

-

Captured at: {formatTimestamp(report.context.timestamp)}

-
- ); -} diff --git a/apps/web/src/components/admin-bug-report-diagnostics.tsx b/apps/web/src/components/admin-bug-report-diagnostics.tsx deleted file mode 100644 index 743b5f73..00000000 --- a/apps/web/src/components/admin-bug-report-diagnostics.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useMemo, useState } from "react"; -import type { BugReportDetail } from "../types/bug-report"; -import { AdminBugReportDiagnosticsDetails } from "./admin-bug-report-diagnostics-details"; -import { AdminBugReportDiagnosticsMeta } from "./admin-bug-report-diagnostics-meta"; - -type Props = { - report: BugReportDetail; -}; - -export function AdminBugReportDiagnostics({ report }: Props) { - const [copied, setCopied] = useState(false); - const diagnosticsJson = useMemo(() => JSON.stringify(report.context, null, 2), [report.context]); - - async function copyDiagnostics() { - if (typeof navigator === "undefined" || !navigator.clipboard) return; - try { - await navigator.clipboard.writeText(diagnosticsJson); - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); - } catch { - setCopied(false); - } - } - - return ( -
-
-

Diagnostics

- -
- - -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-filters.tsx b/apps/web/src/components/admin-bug-report-filters.tsx deleted file mode 100644 index d36ebb6e..00000000 --- a/apps/web/src/components/admin-bug-report-filters.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { CATEGORY_OPTIONS, STATUS_OPTIONS } from "../lib/bug-report-utils"; -import type { BugReportCategory, BugReportStatus } from "../types/bug-report"; - -type Props = { - statusFilter: BugReportStatus | undefined; - categoryFilter: BugReportCategory | undefined; - searchText: string; - onStatusChange: (value: BugReportStatus | undefined) => void; - onCategoryChange: (value: BugReportCategory | undefined) => void; - onSearchChange: (value: string) => void; -}; - -export function AdminBugReportFilters({ - statusFilter, - categoryFilter, - searchText, - onStatusChange, - onCategoryChange, - onSearchChange, -}: Props) { - return ( -
- onSearchChange(e.target.value)} - placeholder="Search id, email, text..." - className="rounded border border-border-strong bg-transparent px-2 py-1.5 text-sm text-fg placeholder:text-fg-soft" - /> - - -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-github-action.tsx b/apps/web/src/components/admin-bug-report-github-action.tsx deleted file mode 100644 index 639c4ece..00000000 --- a/apps/web/src/components/admin-bug-report-github-action.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ExternalLink } from "lucide-react"; -import type { BugReportDetail } from "../types/bug-report"; - -type Props = { - report: BugReportDetail; - busy: boolean; - isAdmin: boolean; - onCreateIssue: () => void; -}; - -export function AdminBugReportGitHubAction({ report, busy, isAdmin, onCreateIssue }: Props) { - if (!isAdmin) return null; - if (report.githubIssueUrl) { - return ( - - - View GitHub Issue - - ); - } - - return ( - - ); -} diff --git a/apps/web/src/components/admin-bug-report-list.tsx b/apps/web/src/components/admin-bug-report-list.tsx deleted file mode 100644 index 4e0a5e46..00000000 --- a/apps/web/src/components/admin-bug-report-list.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { formatTimestamp } from "../lib/bug-report-utils"; -import type { BugReportListItem } from "../types/bug-report"; - -type Props = { - reports: BugReportListItem[]; - selectedId: string | null; - onSelect: (id: string) => void; -}; - -function trimDescription(value: string): string { - if (value.length <= 90) return value; - return `${value.slice(0, 90)}...`; -} - -export function AdminBugReportList({ reports, selectedId, onSelect }: Props) { - if (reports.length === 0) return

No bug reports found.

; - - return ( -
- {reports.map((report) => { - const selected = selectedId === report.id; - return ( - - ); - })} -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-overview.tsx b/apps/web/src/components/admin-bug-report-overview.tsx deleted file mode 100644 index 65f3fd00..00000000 --- a/apps/web/src/components/admin-bug-report-overview.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { formatTimestamp } from "../lib/bug-report-utils"; -import type { BugReportDetail } from "../types/bug-report"; - -type Props = { - report: BugReportDetail; -}; - -export function AdminBugReportOverview({ report }: Props) { - return ( -
-
-

Issue

- {formatTimestamp(report.createdAt)} -
-

{report.status.replace("_", " ")}

-

{report.description}

-

- {report.category.replace("_", " ")} · {report.userEmail} -

- {report.context.videoUrl && ( -

{report.context.videoUrl}

- )} -
- ); -} diff --git a/apps/web/src/components/admin-bug-report-pager.tsx b/apps/web/src/components/admin-bug-report-pager.tsx deleted file mode 100644 index 81e3ddb2..00000000 --- a/apps/web/src/components/admin-bug-report-pager.tsx +++ /dev/null @@ -1,38 +0,0 @@ -type Props = { - page: number; - totalPages: number; - total: number; - onPrev: () => void; - onNext: () => void; -}; - -export function AdminBugReportPager({ page, totalPages, total, onPrev, onNext }: Props) { - return ( -
- - {total} report{total !== 1 ? "s" : ""} - -
- - - Page {page} of {totalPages} - - -
-
- ); -} diff --git a/apps/web/src/components/admin-bug-report-status-control.tsx b/apps/web/src/components/admin-bug-report-status-control.tsx deleted file mode 100644 index a0272359..00000000 --- a/apps/web/src/components/admin-bug-report-status-control.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { STATUS_OPTIONS } from "../lib/bug-report-utils"; -import type { BugReportDetail, BugReportStatus } from "../types/bug-report"; - -type Props = { - report: BugReportDetail; - busy: boolean; - onStatusChange: (status: BugReportStatus) => void; -}; - -export function AdminBugReportStatusControl({ report, busy, onStatusChange }: Props) { - return ( -
-

Status

- -
- ); -} diff --git a/apps/web/src/components/admin-bug-reports-section.tsx b/apps/web/src/components/admin-bug-reports-section.tsx deleted file mode 100644 index 61906ed7..00000000 --- a/apps/web/src/components/admin-bug-reports-section.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useEffect, useState } from "react"; -import { useAdminBugReportDetail, useAdminBugReports } from "../hooks/use-admin-bug-reports"; -import { filterBugReports } from "../lib/admin-bug-report-filter"; -import type { BugReportCategory, BugReportStatus } from "../types/bug-report"; -import { AdminBugReportDetailPanel } from "./admin-bug-report-detail"; -import { AdminBugReportFilters } from "./admin-bug-report-filters"; -import { AdminBugReportList } from "./admin-bug-report-list"; -import { AdminBugReportPager } from "./admin-bug-report-pager"; - -const PAGE_SIZE = 20; - -type Props = { - enabled: boolean; - isAdmin: boolean; - onToast: (message: string) => void; -}; - -export function AdminBugReportsSection({ enabled, isAdmin, onToast }: Props) { - const [page, setPage] = useState(1); - const [statusFilter, setStatusFilter] = useState(); - const [categoryFilter, setCategoryFilter] = useState(); - const [searchText, setSearchText] = useState(""); - const [selectedId, setSelectedId] = useState(null); - const { query, updateStatus, createIssue } = useAdminBugReports(enabled, { - page, - limit: PAGE_SIZE, - }); - const reports = filterBugReports(query.data?.items ?? [], { - status: statusFilter, - category: categoryFilter, - query: searchText, - }); - const total = query.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const { query: detailQuery } = useAdminBugReportDetail(enabled && !!selectedId, selectedId ?? ""); - - useEffect(() => { - if (reports.length > 0 && !selectedId) setSelectedId(reports[0].id); - }, [reports, selectedId]); - - const busy = updateStatus.isPending || createIssue.isPending; - - return ( -
- { - setStatusFilter(v); - setPage(1); - }} - onCategoryChange={(v) => { - setCategoryFilter(v); - setPage(1); - }} - onSearchChange={(value) => { - setSearchText(value); - setSelectedId(null); - }} - /> - setPage((p) => p - 1)} - onNext={() => setPage((p) => p + 1)} - /> - - {query.isPending &&

Loading bug reports...

} - {query.isError &&

Unable to load bug reports.

} - - {!query.isPending && !query.isError && ( -
- - {detailQuery.data ? ( - { - updateStatus.mutate( - { id: detailQuery.data.id, status }, - { - onSuccess: () => onToast("Status updated"), - onError: (e) => onToast(e instanceof Error ? e.message : "Failed to update"), - }, - ); - }} - onCreateIssue={() => { - createIssue.mutate(detailQuery.data.id, { - onSuccess: () => onToast("GitHub issue created"), - onError: (e) => - onToast(e instanceof Error ? e.message : "Failed to create issue"), - }); - }} - /> - ) : ( -

Select a report to inspect details.

- )} -
- )} -
- ); -} diff --git a/apps/web/src/components/admin-console-header.tsx b/apps/web/src/components/admin-console-header.tsx deleted file mode 100644 index 5e95cc0b..00000000 --- a/apps/web/src/components/admin-console-header.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { AdminSection } from "../lib/admin-console-section"; - -type Props = { - section: AdminSection; -}; - -const TITLES: Record = { - settings: "Admin Settings", - "allow-list": "Allow List", - users: "User Management", - sessions: "Active Sessions", - issues: "Issue Triage", -}; - -const DESCRIPTIONS: Record = { - settings: "Global moderation and platform switches.", - "allow-list": "Control which channels are available in allow-list mode.", - users: "Roles, suspension, and account recovery tools.", - sessions: "Connected clients, playback state, and recent activity.", - issues: "Bug reports, diagnostics, status updates, and GitHub sync.", -}; - -export function AdminConsoleHeader({ section }: Props) { - return ( -
-

Admin Console

-

- {TITLES[section]} -

-

{DESCRIPTIONS[section]}

-
- ); -} diff --git a/apps/web/src/components/admin-console-nav.tsx b/apps/web/src/components/admin-console-nav.tsx deleted file mode 100644 index 1c98bebd..00000000 --- a/apps/web/src/components/admin-console-nav.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { AdminSection } from "../lib/admin-console-section"; - -type Item = { - key: AdminSection; - label: string; -}; - -type Props = { - items: Item[]; - active: AdminSection; - onSelect: (section: AdminSection) => void; -}; - -export function AdminConsoleNav({ items, active, onSelect }: Props) { - return ( - - ); -} diff --git a/apps/web/src/components/admin-session-card.tsx b/apps/web/src/components/admin-session-card.tsx deleted file mode 100644 index ddce41c9..00000000 --- a/apps/web/src/components/admin-session-card.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { toApiUrl } from "../lib/env"; -import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji"; -import type { AdminSession } from "../types/admin"; -import type { AuthUser } from "../types/auth"; - -type Props = { - session: AdminSession; - user?: AuthUser; -}; - -function formatDuration(value: number | null | undefined): string { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "0:00"; - const totalSeconds = Math.floor(value / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -function initials(value: string): string { - return value - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]?.toUpperCase() ?? "") - .join(""); -} - -function avatarUrl(user: AuthUser | undefined, session: AdminSession): string | null { - if (user?.avatarType === "emoji" && user.avatarCode) return getOpenMojiUrl(user.avatarCode); - if (user?.avatarUrl) return toApiUrl(user.avatarUrl); - if (user) return getOpenMojiUrl(pickOpenMojiCode(`${user.id}:${user.email}`)); - if (session.userId) return getOpenMojiUrl(pickOpenMojiCode(session.userId)); - return null; -} - -export function AdminSessionCard({ session, user }: Props) { - const name = - user?.publicUsername ?? user?.name ?? session.username ?? session.userId ?? "Unknown user"; - const device = [session.deviceName, session.deviceType].filter(Boolean).join(" - ") || "Browser"; - const nowPlaying = session.nowPlaying; - const stateLabel = nowPlaying ? (nowPlaying.paused ? "Paused" : "Playing") : "Online"; - const imageUrl = avatarUrl(user, session); - const progress = nowPlaying?.durationMs - ? Math.min(100, Math.max(0, (nowPlaying.positionMs / nowPlaying.durationMs) * 100)) - : 0; - - return ( -
-
- {nowPlaying?.thumbnail ? ( - - ) : ( -
-
-
-
-
-
- )} -
-
- - {stateLabel} -
-
-
-
- {imageUrl ? ( - {name} - ) : ( - initials(name) || "U" - )} -
-
-

{name}

-

{device}

-
-
-
-
- -
- {nowPlaying ? ( -
-
-
-

{nowPlaying.title}

-

- {nowPlaying.channelName ?? "Video"} -

-
- - {formatDuration(nowPlaying.positionMs)} - -
-
-
-
-
- {formatDuration(nowPlaying.positionMs)} - {formatDuration(nowPlaying.durationMs)} -
-
- ) : ( -
-

No active playback

-

The client is connected and ready.

-
- )} -
-
- ); -} diff --git a/apps/web/src/components/admin-sessions-section.tsx b/apps/web/src/components/admin-sessions-section.tsx deleted file mode 100644 index 8302d313..00000000 --- a/apps/web/src/components/admin-sessions-section.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useAdminSessions } from "../hooks/use-admin-sessions"; -import { useAdminUsers } from "../hooks/use-admin-users"; -import { AdminSessionCard } from "./admin-session-card"; - -type Props = { - enabled: boolean; -}; - -export function AdminSessionsSection({ enabled }: Props) { - const query = useAdminSessions(enabled); - const usersQuery = useAdminUsers(enabled, 1, 200).query; - const sessions = query.data ?? []; - const users = usersQuery.data?.items ?? []; - - if (query.isPending) { - return ( -
- Loading active sessions... -
- ); - } - - if (query.isError) { - return ( -
- Unable to load active sessions. -
- ); - } - - if (sessions.length === 0) { - return ( -
- No active sessions are currently reported. -
- ); - } - - return ( -
- {sessions.map((session) => ( - item.id === session.userId)} - /> - ))} -
- ); -} diff --git a/apps/web/src/components/admin-settings-panel.tsx b/apps/web/src/components/admin-settings-panel.tsx deleted file mode 100644 index 46b43f55..00000000 --- a/apps/web/src/components/admin-settings-panel.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type { AdminSettings } from "../types/admin"; - -type Props = { - settings: AdminSettings; - pending: boolean; - onToggle: (key: BooleanAdminSetting) => void; -}; - -type BooleanAdminSetting = { - [Key in keyof AdminSettings]: AdminSettings[Key] extends boolean ? Key : never; -}[keyof AdminSettings]; - -const ROW = - "flex items-center justify-between rounded-lg border border-border bg-surface px-4 py-3"; - -export function AdminSettingsPanel({ settings, pending, onToggle }: Props) { - return ( -
-
-
-

Instance access

-

Control who can register or browse as guest.

-
-
- onToggle("allowRegistration")} - /> - onToggle("allowGuest")} - /> - onToggle("activeSessionsEnabled")} - /> -
-
-
-
-

Authentication

-

- OIDC behavior. Disable local login only when an OIDC provider is configured. -

-
-
- onToggle("localLoginEnabled")} - /> - onToggle("oidcAutoRedirect")} - /> -
-
-
-
-

YouTube

-

Allow remote browser sign-in for YouTube sessions.

-
-
- onToggle("youtubeRemoteLoginEnabled")} - /> -
-
-
- ); -} - -type ToggleProps = { - label: string; - value: boolean; - pending: boolean; - onClick: () => void; -}; - -function SettingToggle({ label, value, pending, onClick }: ToggleProps) { - return ( -
- {label} - -
- ); -} diff --git a/apps/web/src/components/admin-settings-section.tsx b/apps/web/src/components/admin-settings-section.tsx deleted file mode 100644 index 7c3b2aa7..00000000 --- a/apps/web/src/components/admin-settings-section.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAdminSettings } from "../hooks/use-admin-settings"; -import type { AdminSettings } from "../types/admin"; -import { AdminSettingsPanel } from "./admin-settings-panel"; - -type Props = { - enabled: boolean; - onToast: (message: string) => void; -}; - -type BooleanAdminSetting = { - [Key in keyof AdminSettings]: AdminSettings[Key] extends boolean ? Key : never; -}[keyof AdminSettings]; - -export function AdminSettingsSection({ enabled, onToast }: Props) { - const adminSettings = useAdminSettings(enabled); - - function updateSettings(next: AdminSettings) { - adminSettings.update.mutate(next, { - onSuccess: () => onToast("Admin settings updated"), - onError: (error) => - onToast(error instanceof Error ? error.message : "Unable to update admin settings"), - }); - } - - function toggleSetting(key: BooleanAdminSetting) { - const current = adminSettings.query.data; - if (!current) return; - updateSettings({ ...current, [key]: !current[key] }); - } - - if (adminSettings.query.isPending) { - return ( -
- Loading admin settings... -
- ); - } - - if (adminSettings.query.isError) { - const message = - adminSettings.query.error instanceof Error - ? adminSettings.query.error.message - : "Unable to load admin settings."; - return ( -
- Unable to load admin settings: {message} -
- ); - } - - if (!adminSettings.query.data) return null; - - return ( - - ); -} diff --git a/apps/web/src/components/admin-user-avatar.tsx b/apps/web/src/components/admin-user-avatar.tsx deleted file mode 100644 index cd9787df..00000000 --- a/apps/web/src/components/admin-user-avatar.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { toApiUrl } from "../lib/env"; -import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji"; -import type { AuthUser } from "../types/auth"; - -type AdminUserAvatarProps = { - user: AuthUser; - className: string; -}; - -export function AdminUserAvatar({ user, className }: AdminUserAvatarProps) { - const seed = `${user.id}:${user.email}`; - const avatarUrl = - user.avatarType === "emoji" && typeof user.avatarCode === "string" && user.avatarCode.length > 0 - ? getOpenMojiUrl(user.avatarCode) - : user.avatarUrl - ? toApiUrl(user.avatarUrl) - : getOpenMojiUrl(pickOpenMojiCode(seed)); - - return ( -
- {user.name -
- ); -} diff --git a/apps/web/src/components/admin-user-detail-panel.tsx b/apps/web/src/components/admin-user-detail-panel.tsx deleted file mode 100644 index 1d1dc961..00000000 --- a/apps/web/src/components/admin-user-detail-panel.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from "react"; -import type { AuthRole, AuthUser } from "../types/auth"; -import { AdminUserAvatar } from "./admin-user-avatar"; -import { AdminUserIdentityForm } from "./admin-user-identity-form"; - -type AdminUserDetailPanelProps = { - user: AuthUser; - busy: boolean; - onRole: (id: string, role: AuthRole) => void; - onSuspend: (id: string, suspended: boolean) => void; - onReset: (id: string, email: string) => void; - onMessage: (message: string) => void; -}; - -const ROLE_OPTIONS: AuthRole[] = ["user", "moderator", "admin"]; - -function roleClass(active: boolean): string { - if (active) return "border-fg bg-fg text-app"; - return "border-border-strong bg-surface text-fg-muted hover:border-border-strong"; -} - -export function AdminUserDetailPanel({ - user, - busy, - onRole, - onSuspend, - onReset, - onMessage, -}: AdminUserDetailPanelProps) { - const [actionsOpen, setActionsOpen] = useState(false); - const suspendClass = user.suspended - ? "border-emerald-800/60 bg-emerald-950/30 text-emerald-200 hover:border-emerald-700" - : "border-danger/60 bg-danger/30 text-danger-strong hover:border-danger"; - - return ( - - ); -} diff --git a/apps/web/src/components/admin-user-grid.tsx b/apps/web/src/components/admin-user-grid.tsx deleted file mode 100644 index 4b344d49..00000000 --- a/apps/web/src/components/admin-user-grid.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { formatCreatedAt } from "../lib/admin-console"; -import type { AuthUser } from "../types/auth"; -import { AdminUserRow } from "./admin-user-row"; - -type AdminUserGridProps = { - users: AuthUser[]; - selectedUserId: string | null; - onSelectUser: (id: string) => void; -}; - -export function AdminUserGrid({ users, selectedUserId, onSelectUser }: AdminUserGridProps) { - return ( -
- {users.map((user, index) => ( -
- onSelectUser(id)} - /> -
- ))} -
- ); -} diff --git a/apps/web/src/components/admin-user-identity-form.tsx b/apps/web/src/components/admin-user-identity-form.tsx deleted file mode 100644 index ceab2bf7..00000000 --- a/apps/web/src/components/admin-user-identity-form.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { updateAdminIdentity } from "../lib/api-account-identity"; -import type { AuthUser } from "../types/auth"; - -type Props = { - user: AuthUser; - disabled: boolean; - onMessage: (message: string) => void; -}; - -export function AdminUserIdentityForm({ user, disabled, onMessage }: Props) { - const queryClient = useQueryClient(); - const [email, setEmail] = useState(user.email); - const [name, setName] = useState(user.name); - const update = useMutation({ - mutationFn: () => updateAdminIdentity(user.id, { email: email.trim(), name: name.trim() }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }), - }); - - useEffect(() => { - setEmail(user.email); - setName(user.name); - }, [user]); - - const dirty = - email.trim().toLowerCase() !== user.email.toLowerCase() || name.trim() !== user.name; - return ( -
- setName(event.target.value)} - className="h-8 rounded-md border border-border-strong bg-app px-2.5 text-xs text-fg" - /> - setEmail(event.target.value)} - className="h-8 rounded-md border border-border-strong bg-app px-2.5 text-xs text-fg" - /> - -
- ); -} diff --git a/apps/web/src/components/admin-user-row.tsx b/apps/web/src/components/admin-user-row.tsx deleted file mode 100644 index 748d9cef..00000000 --- a/apps/web/src/components/admin-user-row.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { AuthUser } from "../types/auth"; -import { AdminUserAvatar } from "./admin-user-avatar"; - -type AdminUserRowProps = { - user: AuthUser; - selected: boolean; - createdAtLabel: string; - onSelect: (id: string) => void; -}; - -function roleClass(role: AuthUser["role"]): string { - if (role === "admin") return "border-sky-800/70 bg-sky-950/40 text-sky-200"; - if (role === "moderator") return "border-amber-800/70 bg-amber-950/40 text-amber-200"; - return "border-border-strong bg-surface text-fg-muted"; -} - -export function AdminUserRow({ user, selected, createdAtLabel, onSelect }: AdminUserRowProps) { - const displayName = user.name.trim().length > 0 ? user.name : user.email; - - return ( - - ); -} diff --git a/apps/web/src/components/admin-user-toolbar.tsx b/apps/web/src/components/admin-user-toolbar.tsx deleted file mode 100644 index 9d6cd87a..00000000 --- a/apps/web/src/components/admin-user-toolbar.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { ChangeEvent } from "react"; -import { ADMIN_FILTERS, type AdminFilter, isAdminFilter } from "../lib/admin-console"; - -type AdminUserToolbarProps = { - search: string; - filter: AdminFilter; - onSearchChange: (value: string) => void; - onFilterChange: (value: AdminFilter) => void; -}; - -export function AdminUserToolbar({ - search, - filter, - onSearchChange, - onFilterChange, -}: AdminUserToolbarProps) { - function handleFilter(event: ChangeEvent) { - const next = event.target.value; - if (!isAdminFilter(next)) return; - onFilterChange(next); - } - - return ( -
-
- onSearchChange(event.target.value)} - placeholder="Search name, email or id" - className="h-9 w-full rounded-md border border-border-strong bg-surface px-3 text-sm text-fg outline-none transition-colors focus:border-border-strong" - /> -
- -
- ); -} diff --git a/apps/web/src/components/admin-users-pagination.tsx b/apps/web/src/components/admin-users-pagination.tsx deleted file mode 100644 index f0c92c29..00000000 --- a/apps/web/src/components/admin-users-pagination.tsx +++ /dev/null @@ -1,50 +0,0 @@ -type AdminUsersPaginationProps = { - page: number; - totalPages: number; - total: number; - pageStart: number; - pageEnd: number; - pending: boolean; - onPrev: () => void; - onNext: () => void; -}; - -export function AdminUsersPagination({ - page, - totalPages, - total, - pageStart, - pageEnd, - pending, - onPrev, - onNext, -}: AdminUsersPaginationProps) { - return ( -
-

- {pageStart}-{pageEnd} of {total} -

-
- - - Page {page} / {totalPages} - - -
-
- ); -} diff --git a/apps/web/src/components/admin-users-section.tsx b/apps/web/src/components/admin-users-section.tsx deleted file mode 100644 index 39dcd887..00000000 --- a/apps/web/src/components/admin-users-section.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useAdminUsers } from "../hooks/use-admin-users"; -import { type AdminFilter, matchesAdminFilter } from "../lib/admin-console"; -import { AdminUserDetailPanel } from "./admin-user-detail-panel"; -import { AdminUserGrid } from "./admin-user-grid"; -import { AdminUserToolbar } from "./admin-user-toolbar"; -import { AdminUsersPagination } from "./admin-users-pagination"; -import { ResetTokenModal } from "./reset-token-modal"; - -const PAGE_SIZE = 50; - -type Props = { - enabled: boolean; - currentUserId: string | null; - onToast: (message: string) => void; -}; - -export function AdminUsersSection({ enabled, currentUserId, onToast }: Props) { - const [page, setPage] = useState(1); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - const [selectedUserId, setSelectedUserId] = useState(null); - const [resetTokenData, setResetTokenData] = useState<{ email: string; token: string } | null>( - null, - ); - const { query, role, suspend, resetToken } = useAdminUsers(enabled, page, PAGE_SIZE); - - const users = query.data?.items ?? []; - const total = query.data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const currentPage = query.data?.page ?? page; - const pageStart = total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1; - const pageEnd = total === 0 ? 0 : Math.min(currentPage * PAGE_SIZE, total); - const searchTerm = search.trim().toLowerCase(); - const busy = role.isPending || suspend.isPending || resetToken.isPending; - - const filtered = useMemo( - () => - users - .filter((user) => { - if (!matchesAdminFilter(user, filter)) return false; - if (!searchTerm) return true; - const haystack = `${user.name} ${user.email} ${user.id}`.toLowerCase(); - return haystack.includes(searchTerm); - }) - .sort((a, b) => Number(new Date(b.createdAt)) - Number(new Date(a.createdAt))), - [users, filter, searchTerm], - ); - - const selectedUser = filtered.find((user) => user.id === selectedUserId) ?? null; - - useEffect(() => { - if (filtered.length === 0) { - setSelectedUserId(null); - return; - } - if (selectedUserId && filtered.some((user) => user.id === selectedUserId)) return; - if (currentUserId && filtered.some((user) => user.id === currentUserId)) { - setSelectedUserId(currentUserId); - return; - } - setSelectedUserId(filtered[0].id); - }, [selectedUserId, filtered, currentUserId]); - - return ( - <> - { - setSearch(value); - setPage(1); - }} - onFilterChange={(value) => { - setFilter(value); - setPage(1); - }} - /> - setPage((value) => Math.max(1, value - 1))} - onNext={() => setPage((value) => Math.min(totalPages, value + 1))} - /> - {query.isPending && ( -
- Loading users... -
- )} - {query.isError && ( -
- Unable to load users right now. -
- )} - {!query.isPending && !query.isError && filtered.length === 0 && ( -
- No user matches this view. -
- )} - {!query.isPending && !query.isError && filtered.length > 0 && ( -
- - {selectedUser ? ( - { - role.mutate( - { id, role: nextRole }, - { - onSuccess: () => onToast(`Role set to ${nextRole}`), - onError: (error) => - onToast(error instanceof Error ? error.message : "Unable to update role"), - }, - ); - }} - onSuspend={(id, suspendedFlag) => { - suspend.mutate( - { id, suspended: !suspendedFlag }, - { - onSuccess: () => - onToast(!suspendedFlag ? "User suspended" : "User unsuspended"), - onError: (error) => - onToast( - error instanceof Error ? error.message : "Unable to update suspension", - ), - }, - ); - }} - onReset={(id, email) => { - resetToken.mutate(id, { - onSuccess: (result) => setResetTokenData({ email, token: result.resetToken }), - onError: (error) => - onToast( - error instanceof Error ? error.message : "Unable to generate reset token", - ), - }); - }} - /> - ) : ( -
- )} -
- )} - {resetTokenData && ( - setResetTokenData(null)} - onCopied={() => onToast("Token copied to clipboard")} - /> - )} - - ); -} diff --git a/apps/web/src/components/allow-channel-button.tsx b/apps/web/src/components/allow-channel-button.tsx deleted file mode 100644 index c2374e17..00000000 --- a/apps/web/src/components/allow-channel-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useAllowedChannels } from "../hooks/use-allowed-channels"; -import { useAuth } from "../hooks/use-auth"; -import { normalizeChannelUrl } from "../lib/channel-url"; - -type Props = { - url: string; - name?: string | null; - thumbnailUrl?: string | null; - compact?: boolean; -}; - -export function AllowChannelButton({ url, name, thumbnailUrl, compact = false }: Props) { - const { authReady, isAuthed } = useAuth(); - const { canGlobalBlock } = useAuth(); - const { query, add } = useAllowedChannels(); - if (!authReady || !isAuthed || !canGlobalBlock) return null; - const normalizedUrl = normalizeChannelUrl(url); - const allowed = (query.data ?? []).some( - (item) => normalizeChannelUrl(item.url) === normalizedUrl || item.name === name, - ); - return ( - - ); -} diff --git a/apps/web/src/components/app-footer.tsx b/apps/web/src/components/app-footer.tsx deleted file mode 100644 index cab6c194..00000000 --- a/apps/web/src/components/app-footer.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Heart } from "lucide-react"; -import { siGithub } from "simple-icons"; -import { ServiceIcon } from "./service-icon"; - -const PROFILE_URL = "https://github.com/Priveetee"; -const SPONSOR_URL = "https://github.com/sponsors/Priveetee"; - -export function AppFooter() { - return ( - - ); -} diff --git a/apps/web/src/components/audio-center-toggle.tsx b/apps/web/src/components/audio-center-toggle.tsx deleted file mode 100644 index 4c031897..00000000 --- a/apps/web/src/components/audio-center-toggle.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge"; -import { useMediaRemote, useMediaState } from "../lib/vidstack"; - -export function AudioCenterToggle({ video = null }: { video?: HTMLVideoElement | null }) { - const remote = useMediaRemote(); - const paused = useMediaState("paused"); - const label = paused ? "Play audio" : "Pause audio"; - - const togglePlayback = async () => { - if (video) return requestSabrVidstackPlayback(video, paused, true); - if (paused) await remote.play(); - else await remote.pause(); - }; - - return ( - - ); -} diff --git a/apps/web/src/components/audio-seek-button.tsx b/apps/web/src/components/audio-seek-button.tsx deleted file mode 100644 index eece3f7a..00000000 --- a/apps/web/src/components/audio-seek-button.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { requestSabrSeek } from "../lib/sabr-vidstack-bridge"; -import { useMediaRemote, useMediaState } from "../lib/vidstack"; -import { AudioSeekBackward10Icon, AudioSeekForward10Icon } from "./audio-control-icons"; - -type Props = { - direction: "backward" | "forward"; - disabled?: boolean; - video?: HTMLVideoElement | null; -}; - -export function AudioSeekButton({ direction, disabled = false, video = null }: Props) { - const remote = useMediaRemote(); - const currentTime = useMediaState("currentTime"); - const duration = useMediaState("duration"); - const seconds = direction === "backward" ? -10 : 10; - const label = direction === "backward" ? "Seek backward 10 seconds" : "Seek forward 10 seconds"; - const Icon = direction === "backward" ? AudioSeekBackward10Icon : AudioSeekForward10Icon; - - const seek = () => { - const target = currentTime + seconds; - const bounded = Math.min(Math.max(target, 0), duration > 0 ? duration : target); - if (video && requestSabrSeek(video, bounded)) return; - remote.seek(bounded); - }; - - return ( - - ); -} diff --git a/apps/web/src/components/audio-time-slider.tsx b/apps/web/src/components/audio-time-slider.tsx deleted file mode 100644 index 0672e95e..00000000 --- a/apps/web/src/components/audio-time-slider.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useState } from "react"; -import { secondsFromSliderPercent } from "../lib/sabr-player-seek"; -import { requestSabrSeek } from "../lib/sabr-vidstack-bridge"; -import { TimeSlider, useMediaRemote, useMediaState } from "../lib/vidstack"; - -type Props = { - disabled?: boolean; - video?: HTMLVideoElement | null; -}; - -export function AudioTimeSlider({ disabled = false, video = null }: Props) { - const [seekTarget, setSeekTarget] = useState(null); - const remote = useMediaRemote(); - const mediaDuration = useMediaState("duration"); - useEffect(() => { - if (!disabled) setSeekTarget(null); - }, [disabled]); - const style = seekTarget === null ? undefined : { "--typetype-seek-target": `${seekTarget}%` }; - - return ( - { - setSeekTarget(percent); - const seconds = secondsFromSliderPercent(video?.duration ?? mediaDuration, percent); - if (disabled || seconds === null) return; - if (video && requestSabrSeek(video, seconds)) return; - remote.seek(seconds); - }} - > - - - - - - - ); -} diff --git a/apps/web/src/components/audio-track-selector.tsx b/apps/web/src/components/audio-track-selector.tsx deleted file mode 100644 index 77543f8e..00000000 --- a/apps/web/src/components/audio-track-selector.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useRef } from "react"; -import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack"; -import { - DefaultMenuButton, - DefaultMenuRadioGroup, - LanguageIcon, - Menu, - useAudioOptions, -} from "../lib/vidstack"; -import { useSabrAudioStore } from "../stores/sabr-audio-store"; -import { includesOriginal, normalizeLanguageTag } from "./player-language"; - -const languageIcon: DefaultLayoutIcon = (props) => ; -const MENU_ITEMS_CLASS = - "vds-menu-items overflow-y-auto overscroll-y-contain pr-0.5 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent"; - -type Props = { - originalLocale?: string | null; - sabr?: boolean; -}; - -export function AudioTrackSelector({ originalLocale, sabr = false }: Props) { - const menuRef = useRef(null); - const nativeOptions = useAudioOptions(); - const sabrStreamId = useSabrAudioStore((state) => state.streamId); - const sabrOptions = useSabrAudioStore((state) => state.options); - const selectedTrackId = useSabrAudioStore((state) => state.selectedTrackId); - const selectSabrTrack = useSabrAudioStore((state) => state.selectTrack); - const options = sabr - ? sabrOptions.map((option) => ({ - label: option.label, - selected: option.id === selectedTrackId, - track: { language: option.language }, - select: () => { - if (sabrStreamId) selectSabrTrack(sabrStreamId, option.id); - }, - })) - : nativeOptions; - - if (options.length <= 1) return null; - - const selectedOption = options.find((o) => o.selected) ?? options[0]; - const selectedTrackLooksOriginal = includesOriginal(selectedOption?.label); - const selectedMatchesOriginalLocale = - originalLocale != null && - normalizeLanguageTag(selectedOption?.track.language) === normalizeLanguageTag(originalLocale); - const selectedIsOriginal = selectedTrackLooksOriginal || selectedMatchesOriginalLocale; - const currentHint = selectedIsOriginal - ? includesOriginal(selectedOption?.label) - ? selectedOption?.label - : `${selectedOption?.label} (original)` - : selectedOption?.label; - - const radioOptions = options.map((o, index) => { - const isOriginal = - includesOriginal(o.label) || - (originalLocale != null && - normalizeLanguageTag(o.track.language) === normalizeLanguageTag(originalLocale)); - return { - label: isOriginal && !includesOriginal(o.label) ? `${o.label} (original)` : o.label, - value: `${o.label}-${o.track.language ?? "und"}-${index}`, - }; - }); - - const selectedValue = - radioOptions.find((_, index) => options[index]?.selected)?.value ?? - radioOptions[0]?.value ?? - ""; - - function onChange(value: string) { - const selectedIndex = radioOptions.findIndex((option) => option.value === value); - options[selectedIndex]?.select(); - menuRef.current?.close(); - } - - return ( - - - - - - - ); -} diff --git a/apps/web/src/components/auth-backdrop.tsx b/apps/web/src/components/auth-backdrop.tsx deleted file mode 100644 index 00289dfc..00000000 --- a/apps/web/src/components/auth-backdrop.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ReactNode } from "react"; -import { ThemeToggleButton } from "./theme-toggle-button"; - -type Props = { - children: ReactNode; - fixed?: boolean; - contentClassName: string; -}; - -export function AuthBackdrop({ children, fixed = false, contentClassName }: Props) { - const shellClass = fixed - ? "fixed inset-0 z-50 overflow-hidden bg-app text-fg" - : "relative min-h-screen overflow-hidden bg-app text-fg"; - - return ( -
-
-
-
-
-
-
-
-
- -
-
{children}
-
- ); -} diff --git a/apps/web/src/components/auth-card.tsx b/apps/web/src/components/auth-card.tsx deleted file mode 100644 index 03e0e85e..00000000 --- a/apps/web/src/components/auth-card.tsx +++ /dev/null @@ -1,17 +0,0 @@ -type Props = { - title: string; - subtitle: string; - children: React.ReactNode; -}; - -export function AuthCard({ title, subtitle, children }: Props) { - return ( -
-
-

{title}

-

{subtitle}

-
- {children} -
- ); -} diff --git a/apps/web/src/components/auth-error-banner.tsx b/apps/web/src/components/auth-error-banner.tsx deleted file mode 100644 index 804d56d2..00000000 --- a/apps/web/src/components/auth-error-banner.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type Props = { - message: string | null; -}; - -export function AuthErrorBanner({ message }: Props) { - if (!message) return null; - return ( -
- {message} -
- ); -} diff --git a/apps/web/src/components/autoplay-countdown-overlay.tsx b/apps/web/src/components/autoplay-countdown-overlay.tsx deleted file mode 100644 index 78ca66a9..00000000 --- a/apps/web/src/components/autoplay-countdown-overlay.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Pause, Play, X } from "lucide-react"; -import type { MouseEvent, PointerEvent } from "react"; -import type { AutoplayTarget } from "../hooks/use-watch-ended-navigation"; -import { proxyImage } from "../lib/proxy"; - -type Props = { - target: AutoplayTarget; - totalSeconds: number; - paused: boolean; - onPlayNow: () => void; - onCancel: () => void; - onPauseToggle: () => void; -}; - -export function AutoplayCountdownOverlay({ - target, - totalSeconds, - paused, - onPlayNow, - onCancel, - onPauseToggle, -}: Props) { - const thumbnail = proxyImage(target.thumbnail); - const progressStyle = { - animationDuration: `${totalSeconds}s`, - animationPlayState: paused ? "paused" : "running", - }; - const pauseButtonClass = paused - ? "inline-flex h-9 w-9 items-center justify-center rounded-full bg-sky-500/20 text-sky-100 ring-1 ring-sky-300/45 transition hover:bg-sky-500/30 hover:ring-sky-200/60" - : "inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/20 transition hover:bg-black/60 hover:ring-white/35"; - - function stopOverlayEvent(event: MouseEvent | PointerEvent) { - event.preventDefault(); - event.stopPropagation(); - } - - function stopPointerEvent(event: PointerEvent) { - event.stopPropagation(); - } - - function handlePlayNow(event: MouseEvent) { - stopOverlayEvent(event); - onPlayNow(); - } - - function handleCancel(event: MouseEvent) { - stopOverlayEvent(event); - onCancel(); - } - - function handlePauseToggle(event: MouseEvent) { - stopOverlayEvent(event); - onPauseToggle(); - } - - return ( -
- {thumbnail ? ( - - ) : ( -
- )} -
- -
-
-

- Up next -

-

- {target.title} -

- {target.channelName && ( -

{target.channelName}

- )} -
- - -
-
-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/autoplay-toggle.tsx b/apps/web/src/components/autoplay-toggle.tsx deleted file mode 100644 index 493f632b..00000000 --- a/apps/web/src/components/autoplay-toggle.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useSettings } from "../hooks/use-settings"; - -export function AutoplayToggle() { - const { settings, update } = useSettings(); - - return ( -
- Autoplay - -
- ); -} diff --git a/apps/web/src/components/caption-style-restorer.tsx b/apps/web/src/components/caption-style-restorer.tsx deleted file mode 100644 index 0204ffa4..00000000 --- a/apps/web/src/components/caption-style-restorer.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useRef } from "react"; -import { - applyCaptionStyles, - readCaptionStylesFromStorage, - writeCaptionStylesToStorage, -} from "../lib/caption-styles"; -import { useMediaPlayer, useMediaState } from "../lib/vidstack"; -import type { CaptionStyles } from "../types/user"; - -type Props = { - captionStyles: CaptionStyles; - settingsReady: boolean; - onChange: (styles: CaptionStyles) => void; -}; - -export function CaptionStyleRestorer({ captionStyles, settingsReady, onChange }: Props) { - const player = useMediaPlayer(); - const canPlay = useMediaState("canPlay"); - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - const applyingRef = useRef(false); - const restoredRef = useRef(false); - - useEffect(() => { - const el = player?.el; - if (!el || !settingsReady || !canPlay || restoredRef.current) return; - restoredRef.current = true; - applyingRef.current = true; - writeCaptionStylesToStorage(captionStyles); - applyCaptionStyles(el, captionStyles); - requestAnimationFrame(() => { - applyingRef.current = false; - }); - }, [player, settingsReady, canPlay, captionStyles]); - - useEffect(() => { - const el = player?.el; - if (!el) return; - let timer: ReturnType | null = null; - const observer = new MutationObserver(() => { - if (applyingRef.current) return; - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - onChangeRef.current(readCaptionStylesFromStorage()); - }, 600); - }); - observer.observe(el, { attributes: true, attributeFilter: ["style"] }); - return () => { - observer.disconnect(); - if (timer) clearTimeout(timer); - }; - }, [player]); - - return null; -} diff --git a/apps/web/src/components/card-liquid-fill.tsx b/apps/web/src/components/card-liquid-fill.tsx deleted file mode 100644 index cefe4c32..00000000 --- a/apps/web/src/components/card-liquid-fill.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useEffect, useRef } from "react"; - -type Props = { - progress: number; -}; - -function getColor(p: number): { h: number; s: number; l: number } { - if (p < 40) return { h: 4, s: 72, l: 45 }; - if (p < 75) return { h: 38, s: 85, l: 50 }; - return { h: 145, s: 65, l: 42 }; -} - -export function CardLiquidFill({ progress }: Props) { - const canvasRef = useRef(null); - const animRef = useRef({ level: 0, time: 0 }); - const frameRef = useRef(0); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const resize = () => { - const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - }; - resize(); - window.addEventListener("resize", resize); - - let running = true; - const anim = animRef.current; - - const draw = () => { - if (!running) return; - const rect = canvas.getBoundingClientRect(); - const w = rect.width; - const h = rect.height; - - ctx.clearRect(0, 0, w, h); - anim.time += 0.02; - anim.level += (progress - anim.level) * 0.03; - - const fillHeight = (anim.level / 100) * h; - const surfaceY = h - fillHeight; - const color = getColor(anim.level); - - const grad = ctx.createLinearGradient(0, surfaceY, 0, h); - grad.addColorStop(0, `hsla(${color.h}, ${color.s}%, ${color.l + 15}%, 0.35)`); - grad.addColorStop(0.5, `hsla(${color.h}, ${color.s}%, ${color.l}%, 0.45)`); - grad.addColorStop(1, `hsla(${color.h}, ${color.s - 10}%, ${color.l - 8}%, 0.55)`); - - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.moveTo(0, surfaceY); - - for (let x = 0; x <= w; x += 4) { - const wave = - Math.sin(x * 0.02 + anim.time * 1.5) * 3 + - Math.sin(x * 0.035 + anim.time * 2.2) * 2 + - Math.sin(x * 0.05 + anim.time * 0.8) * 1.5; - ctx.lineTo(x, surfaceY + wave); - } - - ctx.lineTo(w, h); - ctx.lineTo(0, h); - ctx.closePath(); - ctx.fill(); - - ctx.fillStyle = `hsla(${color.h + 20}, ${color.s - 20}%, ${color.l + 25}%, 0.08)`; - ctx.beginPath(); - ctx.ellipse( - w * 0.7, - surfaceY + fillHeight * 0.4, - w * 0.25, - fillHeight * 0.2, - -0.1, - 0, - Math.PI * 2, - ); - ctx.fill(); - - frameRef.current = requestAnimationFrame(draw); - }; - - frameRef.current = requestAnimationFrame(draw); - return () => { - running = false; - cancelAnimationFrame(frameRef.current); - window.removeEventListener("resize", resize); - }; - }, [progress]); - - return ( - - ); -} diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx deleted file mode 100644 index 32072b28..00000000 --- a/apps/web/src/components/channel-avatar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from "react"; - -type Props = { - src: string; - name: string; - className?: string; -}; - -function getInitial(name: string): string { - if (!name) return "?"; - if (name.startsWith("http")) { - try { - const segments = new URL(name).pathname.split("/").filter(Boolean); - const last = segments.pop() ?? ""; - return (last.replace("@", "")[0] ?? "?").toUpperCase(); - } catch { - return "?"; - } - } - return name[0].toUpperCase(); -} - -export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) { - const [failedSrc, setFailedSrc] = useState(null); - const failed = failedSrc === src; - - if (!src || failed) { - return ( -
- {getInitial(name)} -
- ); - } - - return ( - setFailedSrc(src)} - title={name} - /> - ); -} diff --git a/apps/web/src/components/channel-filter-bar.tsx b/apps/web/src/components/channel-filter-bar.tsx deleted file mode 100644 index 55f235ce..00000000 --- a/apps/web/src/components/channel-filter-bar.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type { FormEvent } from "react"; -import { useEffect, useState } from "react"; -import type { ChannelSort } from "../lib/api-discovery"; -import type { ChannelTab } from "../lib/channel-route-url"; -import { CHANNEL_SORT_OPTIONS, channelSortOrDefault } from "../lib/channel-sort"; - -const CHANNEL_TABS: { tab: ChannelTab; label: string }[] = [ - { tab: "videos", label: "Videos" }, - { tab: "live", label: "Live" }, - { tab: "playlists", label: "Playlists" }, -]; - -type Props = { - sort: ChannelSort; - query: string; - tab: ChannelTab; - searchAvailable: boolean; - onSearch: (query: string) => void; - onTabChange: (tab: ChannelTab) => void; - onSortChange: (sort: ChannelSort) => void; -}; - -export function ChannelFilterBar({ - sort, - query, - tab, - searchAvailable, - onSearch, - onTabChange, - onSortChange, -}: Props) { - const [input, setInput] = useState(query); - const trimmedInput = input.trim(); - const isSearching = query.length > 0; - - useEffect(() => { - setInput(query); - }, [query]); - - function submitSearch(event: FormEvent) { - event.preventDefault(); - if (searchAvailable) onSearch(trimmedInput); - } - - function clearSearch() { - setInput(""); - onSearch(""); - } - - return ( -
-
- {searchAvailable && ( -
- {CHANNEL_TABS.map((item) => ( - - ))} -
- )} - {searchAvailable && tab === "videos" && ( -
-
- - Channel - - setInput(event.target.value)} - placeholder="Search this channel" - className="h-9 min-w-0 flex-1 border-border-strong border-b bg-transparent text-sm text-fg outline-none transition-colors placeholder:text-fg-soft focus:border-fg" - /> - {input.length > 0 && ( - - )} - -
-
- )} - {!isSearching && tab === "videos" && ( -
- {CHANNEL_SORT_OPTIONS.map((option) => { - const selected = option.value === sort; - return ( - - ); - })} -
- )} -
- {isSearching && ( -
- - Search results for {query}, ranked by YouTube. - - -
- )} -
- ); -} diff --git a/apps/web/src/components/channel-page-content.tsx b/apps/web/src/components/channel-page-content.tsx deleted file mode 100644 index cf57e7bc..00000000 --- a/apps/web/src/components/channel-page-content.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useMemo } from "react"; -import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import { useChannel } from "../hooks/use-channel"; -import { useDocumentTitle } from "../hooks/use-document-title"; -import { useSubscriptions } from "../hooks/use-subscriptions"; -import { isChannelNotAllowedError } from "../lib/allow-list-error"; -import { ApiError } from "../lib/api"; -import type { ChannelSort } from "../lib/api-discovery"; -import type { ChannelTab } from "../lib/channel-route-url"; -import { detectProvider } from "../lib/provider"; -import { ChannelFilterBar } from "./channel-filter-bar"; -import { ChannelPageHeader } from "./channel-page-header"; -import { ChannelPlaylistsSection } from "./channel-playlists-section"; -import { ChannelPodcastsSection } from "./channel-podcasts-section"; -import { FamilyListEmptyState } from "./family-list-empty-state"; -import { PageSpinner } from "./page-spinner"; -import { ScrollSentinel } from "./scroll-sentinel"; -import { VideoCard } from "./video-card"; -import { VideoGridSkeleton } from "./video-grid-skeleton"; - -type Props = { - sourceUrl: string; - sort: ChannelSort; - searchQuery: string; - tab: ChannelTab; - onNavigate: (sort: ChannelSort, query: string, tab: ChannelTab) => void; -}; - -export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNavigate }: Props) { - const live = tab === "live"; - const { - meta, - videos, - isLoading, - isError, - error, - refetch, - hasNextPage, - isFetching, - isFetchingNextPage, - fetchNextPage, - } = useChannel(sourceUrl, sort, searchQuery, live); - const { add, remove, isSubscribed } = useSubscriptions(); - const { filter } = useBlockedFilter(); - useDocumentTitle(meta?.name); - - const subscribed = isSubscribed(sourceUrl); - const searchAvailable = detectProvider(sourceUrl) === "youtube"; - const visibleVideos = useMemo(() => filter(videos), [filter, videos]); - const isInitialLoading = isLoading && !meta; - const isReplacingVideos = isFetching && !isFetchingNextPage && visibleVideos.length === 0; - - function handleSubscribe() { - if (!meta) return; - if (subscribed) { - remove.mutate(sourceUrl); - } else { - add.mutate({ channelUrl: sourceUrl, name: meta.name, avatarUrl: meta.avatarUrl }); - } - } - - function selectSort(nextSort: ChannelSort) { - onNavigate(nextSort, searchQuery, tab); - } - - function searchChannel(nextQuery: string) { - onNavigate(sort, searchAvailable && tab === "videos" ? nextQuery : "", tab); - } - - function selectTab(nextTab: ChannelTab) { - onNavigate(sort, "", nextTab); - } - - if (isInitialLoading) return ; - if (isError) { - if (isChannelNotAllowedError(error)) { - return ( - - ); - } - const message = error instanceof ApiError ? error.message : "Unable to load channel right now."; - return ( -
-

{message}

- -
- ); - } - - return ( -
- {meta && ( - - )} - {tab === "videos" && ( - - )} - - {tab === "playlists" ? ( - - ) : ( - <> - {isReplacingVideos ? ( - - ) : ( -
- {visibleVideos.map((v, index) => ( -
- -
- ))} -
- )} - {isFetchingNextPage && } - - - )} -
- ); -} diff --git a/apps/web/src/components/channel-page-header.tsx b/apps/web/src/components/channel-page-header.tsx deleted file mode 100644 index b367cd37..00000000 --- a/apps/web/src/components/channel-page-header.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { formatViews } from "../lib/format"; -import { AllowChannelButton } from "./allow-channel-button"; -import { ChannelAvatar } from "./channel-avatar"; -import { VerifiedBadgeIcon } from "./watch-icons"; - -type Props = { - sourceUrl: string; - name: string; - avatarUrl: string; - bannerUrl: string; - subscriberCount: number; - isVerified: boolean; - subscribed: boolean; - onSubscribe: () => void; -}; - -export function ChannelPageHeader({ - sourceUrl, - name, - avatarUrl, - bannerUrl, - subscriberCount, - isVerified, - subscribed, - onSubscribe, -}: Props) { - return ( -
- {bannerUrl && } -
-
- -
-

- {name} - {isVerified && } -

-

{formatViews(subscriberCount)} subscribers

-
-
-
- - -
-
-
- ); -} diff --git a/apps/web/src/components/channel-playlists-section.tsx b/apps/web/src/components/channel-playlists-section.tsx deleted file mode 100644 index 321eab66..00000000 --- a/apps/web/src/components/channel-playlists-section.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useChannelPlaylists } from "../hooks/use-channel-playlists"; -import { PageSpinner } from "./page-spinner"; -import { PublicPlaylistCard } from "./public-playlist-card"; -import { ScrollSentinel } from "./scroll-sentinel"; - -type Props = { - channelUrl: string; -}; - -export function ChannelPlaylistsSection({ channelUrl }: Props) { - const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = - useChannelPlaylists(channelUrl); - const playlists = (data?.pages.flatMap((p) => p.playlists) ?? []).filter( - (pl, i, arr) => arr.findIndex((x) => x.url === pl.url) === i, - ); - - if (isLoading) return ; - if (playlists.length === 0) { - return

No playlists on this channel.

; - } - - return ( -
-
- {playlists.map((pl) => ( - - ))} -
- -
- ); -} diff --git a/apps/web/src/components/channel-podcasts-section.tsx b/apps/web/src/components/channel-podcasts-section.tsx deleted file mode 100644 index 785f893a..00000000 --- a/apps/web/src/components/channel-podcasts-section.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { usePodcasts } from "../hooks/use-podcasts"; -import { detectProvider } from "../lib/provider"; -import { PodcastCard } from "./podcast-card"; - -const SKELETON_KEYS = [ - "podcast-skeleton-1", - "podcast-skeleton-2", - "podcast-skeleton-3", - "podcast-skeleton-4", -]; - -type Props = { - channelUrl: string; - channelAvatar?: string; -}; - -export function ChannelPodcastsSection({ channelUrl, channelAvatar }: Props) { - const isYoutube = detectProvider(channelUrl) === "youtube"; - const query = usePodcasts(channelUrl, isYoutube); - const podcasts = query.data?.pages.flatMap((page) => page.podcasts) ?? []; - - if (!isYoutube || query.isError || (query.isFetched && podcasts.length === 0)) return null; - - return ( -
-
-
-

Podcasts

-

Podcast playlists from this channel

-
- {query.hasNextPage && ( - - )} -
- {query.isLoading ? ( -
- {SKELETON_KEYS.map((key) => ( -
- ))} -
- ) : ( -
- {podcasts.map((podcast) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/web/src/components/channel-route-link.tsx b/apps/web/src/components/channel-route-link.tsx deleted file mode 100644 index e490ba70..00000000 --- a/apps/web/src/components/channel-route-link.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import type { ReactNode } from "react"; -import type { ChannelSort } from "../lib/api-discovery"; -import { - channelLegacySearch, - channelPathSearch, - toChannelPathParam, -} from "../lib/channel-route-url"; - -type Props = { - url: string; - children: ReactNode; - className?: string; - sort?: ChannelSort; - query?: string; -}; - -export function ChannelRouteLink({ url, children, className, sort = "latest", query = "" }: Props) { - const channelId = toChannelPathParam(url); - - if (channelId) { - return ( - - {children} - - ); - } - - return ( - - {children} - - ); -} diff --git a/apps/web/src/components/cinema-mode-control.tsx b/apps/web/src/components/cinema-mode-control.tsx deleted file mode 100644 index ff45f47a..00000000 --- a/apps/web/src/components/cinema-mode-control.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useWatchLayoutStore } from "../stores/watch-layout-store"; - -function CinemaModeIcon() { - return ( - - Cinema mode - - - - - - ); -} - -export function CinemaModeControl() { - const cinemaMode = useWatchLayoutStore((state) => state.cinemaMode); - const toggleCinemaMode = useWatchLayoutStore((state) => state.toggleCinemaMode); - - return ( - - ); -} diff --git a/apps/web/src/components/collection-page-header.tsx b/apps/web/src/components/collection-page-header.tsx deleted file mode 100644 index 65df886c..00000000 --- a/apps/web/src/components/collection-page-header.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { PlaylistActions } from "./playlist-actions"; - -type Props = { - title: string; - count: number; - loading: boolean; - canPlay: boolean; - onPlayAll: () => void; - onShuffle: () => void; -}; - -export function CollectionPageHeader({ - title, - count, - loading, - canPlay, - onPlayAll, - onShuffle, -}: Props) { - return ( -
-
-

{title}

-

- {loading ? "Loading videos" : `${count} video${count !== 1 ? "s" : ""}`} -

-
- {canPlay && } -
- ); -} diff --git a/apps/web/src/components/confirm-modal.tsx b/apps/web/src/components/confirm-modal.tsx deleted file mode 100644 index 51383e4f..00000000 --- a/apps/web/src/components/confirm-modal.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from "react"; -import { createPortal } from "react-dom"; - -type Props = { - title: string; - description?: string; - confirmLabel?: string; - onConfirm: () => void; - onCancel: () => void; -}; - -export function ConfirmModal({ - title, - description, - confirmLabel = "Delete", - onConfirm, - onCancel, -}: Props) { - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onCancel(); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = prev; - document.removeEventListener("keydown", onKeyDown); - }; - }, [onCancel]); - - return createPortal( - <> -
-
-
-

- {title} -

- {description &&

{description}

} -
-
- - -
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/continue-card.tsx b/apps/web/src/components/continue-card.tsx deleted file mode 100644 index e39aa4c8..00000000 --- a/apps/web/src/components/continue-card.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { useClientLocale } from "../hooks/use-client-locale"; -import { useDeArrowBranding } from "../hooks/use-dearrow"; -import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; -import { proxyImage } from "../lib/proxy"; -import { watchRouteSearch } from "../lib/watch-url"; -import { useWatchNavigationStore } from "../stores/watch-navigation-store"; -import type { VideoStream } from "../types/stream"; -import type { HistoryItem } from "../types/user"; -import { ChannelRouteLink } from "./channel-route-link"; -import { HistoryChannelAvatar } from "./history-channel-avatar"; -import { VideoCardFeedbackMenu } from "./video-card-feedback-menu"; -import { VideoProgressBar } from "./video-progress-bar"; -import { VerifiedBadgeIcon } from "./watch-icons"; - -type ContinueCardProps = { - item: HistoryItem; -}; - -export function ContinueCard({ item }: ContinueCardProps) { - const locale = useClientLocale(); - const setNavigation = useWatchNavigationStore((state) => state.setNavigation); - const uploaderVerified = item.uploaderVerified ?? false; - const branding = useDeArrowBranding( - item.url, - item.title, - proxyImage(item.thumbnail), - item.duration, - ); - const thumbnail = branding.thumbnail; - const publishedText = formatPublishedDate(item.publishedAt, undefined, locale); - const viewsText = item.viewCount === undefined ? "" : formatViews(item.viewCount); - const metaText = [viewsText, publishedText].filter(Boolean).join(" · "); - const menuStream: VideoStream = { - id: item.url, - title: item.title, - thumbnail, - rawThumbnail: item.thumbnail, - rawChannelAvatar: item.channelAvatar ?? "", - channelName: item.channelName, - channelUrl: item.channelUrl || undefined, - channelAvatar: proxyImage(item.channelAvatar ?? ""), - uploaderVerified, - views: item.viewCount ?? 0, - duration: item.duration, - publishedAt: item.publishedAt, - }; - - return ( -
- setNavigation(menuStream)} - > -
- {branding.title} - - {formatDuration(item.duration)} - - -
- - {branding.title} - - -
-
- {item.channelUrl ? ( - - - - ) : ( - - )} -
- {item.channelUrl ? ( - - {item.channelName} - {uploaderVerified && } - - ) : ( - - {item.channelName} - {uploaderVerified && } - - )} - {metaText && {metaText}} -
-
- -
-
- ); -} diff --git a/apps/web/src/components/continue-watching.tsx b/apps/web/src/components/continue-watching.tsx deleted file mode 100644 index 0d715b9d..00000000 --- a/apps/web/src/components/continue-watching.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useHistory } from "../hooks/use-history"; -import { isVideoInProgress } from "../lib/watch-progress"; -import { ContinueCard } from "./continue-card"; - -const MAX_ITEMS = 12; - -export function ContinueWatching() { - const { items } = useHistory(); - const displayed = items - .filter((h) => isVideoInProgress(h.progress, h.duration)) - .sort((a, b) => b.watchedAt - a.watchedAt) - .slice(0, MAX_ITEMS); - if (displayed.length === 0) return null; - - return ( -
-

- Continue watching -

-
- {displayed.map((item, index) => ( -
- -
- ))} -
-
- ); -} diff --git a/apps/web/src/components/custom-avatar-upload.tsx b/apps/web/src/components/custom-avatar-upload.tsx deleted file mode 100644 index cba99add..00000000 --- a/apps/web/src/components/custom-avatar-upload.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { ImageUp } from "lucide-react"; -import { useRef } from "react"; -import { useAvatar } from "../hooks/use-avatar"; - -const MAX_BYTES = 10 * 1024 * 1024; -const ACCEPTED_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); - -type Props = { - onMessage: (message: string) => void; -}; - -export function CustomAvatarUpload({ onMessage }: Props) { - const inputRef = useRef(null); - const { custom } = useAvatar(); - - function upload(file: File | undefined) { - if (!file) return; - if (!ACCEPTED_TYPES.has(file.type)) { - onMessage("Use a PNG, JPEG, WebP, or GIF image"); - return; - } - if (file.size > MAX_BYTES) { - onMessage("Avatar must be 10 MB or smaller"); - return; - } - custom.mutate(file, { - onSuccess: () => onMessage("Avatar updated"), - onError: () => onMessage("Unable to upload avatar"), - }); - } - - return ( -
-
-

Custom image

-

PNG, JPEG, WebP, or animated GIF up to 10 MB

-
- { - upload(event.target.files?.[0]); - event.target.value = ""; - }} - /> - -
- ); -} diff --git a/apps/web/src/components/danmaku-controls.tsx b/apps/web/src/components/danmaku-controls.tsx deleted file mode 100644 index 40c5e727..00000000 --- a/apps/web/src/components/danmaku-controls.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useDanmakuStore } from "../stores/danmaku-store"; -import { DanmakuIcon } from "./watch-icons"; - -const BTN = "flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors"; -const BTN_IDLE = "text-fg-muted hover:text-fg hover:bg-surface-strong"; -const BTN_ON = "text-fg bg-surface-strong"; -const SLIDER = "w-20 accent-zinc-400 cursor-pointer"; - -export function DanmakuControls() { - const { on, speed, size, toggle, setSpeed, setSize } = useDanmakuStore(); - return ( -
- - {on && ( -
- - -
- )} -
- ); -} diff --git a/apps/web/src/components/danmaku-item.tsx b/apps/web/src/components/danmaku-item.tsx deleted file mode 100644 index 1d92bdef..00000000 --- a/apps/web/src/components/danmaku-item.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { argbToColor, CHAR_WIDTH_PX, LANE_HEIGHT_PX, REGULAR_DISPLAY_MS } from "../lib/danmaku"; -import type { BulletCommentItem } from "../types/api"; - -type Props = { - comment: BulletCommentItem; - lane: number; - containerWidth: number; - elapsedMs: number; - speedMultiplier: number; - sizeMultiplier: number; -}; - -const BASE: React.CSSProperties = { - position: "absolute", - whiteSpace: "nowrap", - fontWeight: "bold", - textShadow: "1px 1px 2px rgba(0,0,0,0.8)", - pointerEvents: "none", - userSelect: "none", -}; - -export function DanmakuItem({ - comment, - lane, - containerWidth, - elapsedMs, - speedMultiplier, - sizeMultiplier, -}: Props) { - const color = argbToColor(comment.argbColor); - const fontSize = Math.round(20 * comment.relativeFontSize * sizeMultiplier); - - if (comment.position === "REGULAR") { - const estimatedWidth = comment.text.length * CHAR_WIDTH_PX; - return ( - - {comment.text} - - ); - } - - if (comment.position === "TOP" || comment.position === "SUPERCHAT") { - return ( - - {comment.text} - - ); - } - - return null; -} diff --git a/apps/web/src/components/danmaku-overlay.tsx b/apps/web/src/components/danmaku-overlay.tsx deleted file mode 100644 index de243a41..00000000 --- a/apps/web/src/components/danmaku-overlay.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { displayDuration, N_LANES, REGULAR_DISPLAY_MS } from "../lib/danmaku"; -import { useDanmakuStore } from "../stores/danmaku-store"; -import type { BulletCommentItem } from "../types/api"; -import { DanmakuItem } from "./danmaku-item"; - -type Props = { - comments: BulletCommentItem[]; - positionRef: React.RefObject; -}; - -type IndexedComment = BulletCommentItem & { lane: number; id: number }; - -export function DanmakuOverlay({ comments, positionRef }: Props) { - const overlayRef = useRef(null); - const [visible, setVisible] = useState([]); - const startMsMap = useRef(new Map()); - const { speed, size } = useDanmakuStore(); - - const indexed = useMemo( - () => - comments - .filter((c) => c.position !== "BOTTOM") - .map((c, i) => ({ ...c, lane: i % N_LANES, id: i })), - [comments], - ); - - useEffect(() => { - let rafId: number; - let prevKey = ""; - - function tick() { - const ms = positionRef.current; - const currentSpeed = useDanmakuStore.getState().speed; - const vis = indexed.filter((c) => { - const elapsed = ms - c.durationMs; - const dur = - c.position === "REGULAR" - ? REGULAR_DISPLAY_MS / currentSpeed - : displayDuration(c.position); - return elapsed >= 0 && elapsed < dur; - }); - const key = vis.map((c) => c.id).join(","); - if (key !== prevKey) { - prevKey = key; - for (const c of vis) { - if (!startMsMap.current.has(c.id)) { - startMsMap.current.set(c.id, ms); - } - } - for (const id of startMsMap.current.keys()) { - if (!vis.some((c) => c.id === id)) { - startMsMap.current.delete(id); - } - } - setVisible(vis); - } - rafId = requestAnimationFrame(tick); - } - - rafId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafId); - }, [indexed, positionRef]); - - const width = overlayRef.current?.offsetWidth ?? 0; - - return ( -
- {visible.map((c) => ( - - ))} -
- ); -} diff --git a/apps/web/src/components/download-mode-button.tsx b/apps/web/src/components/download-mode-button.tsx deleted file mode 100644 index c16d4ec5..00000000 --- a/apps/web/src/components/download-mode-button.tsx +++ /dev/null @@ -1,19 +0,0 @@ -type Props = { - active: boolean; - onClick: () => void; - label: string; -}; - -export function DownloadModeButton({ active, onClick, label }: Props) { - return ( - - ); -} diff --git a/apps/web/src/components/download-option-button.tsx b/apps/web/src/components/download-option-button.tsx deleted file mode 100644 index b17dc732..00000000 --- a/apps/web/src/components/download-option-button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { DownloadOption } from "./download-options"; - -type Props = { - option: DownloadOption; - selected: boolean; - onSelect: () => void; - index: number; - compact?: boolean; -}; - -export function DownloadOptionButton({ - option, - selected, - onSelect, - index, - compact = false, -}: Props) { - return ( - - ); -} diff --git a/apps/web/src/components/download-options.ts b/apps/web/src/components/download-options.ts deleted file mode 100644 index 1b9147db..00000000 --- a/apps/web/src/components/download-options.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { DownloaderCreateJobRequest, DownloaderMode } from "../types/downloader"; -import type { VideoStream } from "../types/stream"; - -export type DownloadMode = "video" | "audio"; - -export type DownloadOption = { - id: string; - mode: DownloadMode; - label: string; - detail: string; - size: string; - recommended: boolean; - videoItag?: string; - audioItag?: string; - width?: number; - height?: number; - fps?: number; - videoCodec?: string; - audioCodec?: string; - bitrate?: number; - format: string; - qualityHint: string; -}; - -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return "Unknown size"; - const units = ["B", "KB", "MB", "GB"]; - let value = bytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; - return `${value.toFixed(decimals)} ${units[unitIndex]}`; -} - -function parseContainer(mimeType: string): string { - const [type] = mimeType.split(";"); - const [, container = "bin"] = type.split("/"); - return container.toUpperCase(); -} - -function effectiveVideoHeight(height?: number, width?: number): number { - if (typeof height !== "number" || height <= 0) return 0; - if (typeof width !== "number" || width <= 0) return height; - return Math.min(height, width); -} - -function pickVideoQuality(height: number, width?: number): string { - const effective = effectiveVideoHeight(height, width); - if (effective >= 1080) return "best"; - if (effective >= 720) return "balanced"; - return "small"; -} - -function pickAudioQuality(bitrate: number): string { - if (bitrate >= 192) return "best"; - if (bitrate >= 128) return "balanced"; - return "small"; -} - -function pickRecommendedVideoIndex(videos: DownloadOption[]): number { - const fullHd = videos.findIndex( - (video) => effectiveVideoHeight(video.height, video.width) === 1080, - ); - if (fullHd >= 0) return fullHd; - const hd = videos.findIndex((video) => effectiveVideoHeight(video.height, video.width) === 720); - if (hd >= 0) return hd; - const belowFullHd = videos.findIndex( - (video) => effectiveVideoHeight(video.height, video.width) < 1080, - ); - if (belowFullHd >= 0) return belowFullHd; - return videos.length - 1; -} - -export function buildDownloadOptions(stream: VideoStream): DownloadOption[] { - const videos = [...(stream.videoOnlyStreams ?? [])] - .sort((left, right) => right.height - left.height || right.fps - left.fps) - .map((item, index) => { - const resolution = item.resolution || (item.height > 0 ? `${item.height}p` : "Video"); - const fps = item.fps > 0 ? ` ${item.fps}fps` : ""; - const codec = item.codec ?? "video"; - const container = parseContainer(item.mimeType); - return { - id: `video-${item.itag}-${index}`, - mode: "video" as const, - label: `${resolution}${fps} ${container}`, - detail: `${codec} · itag ${item.itag}`, - size: formatBytes(item.contentLength), - recommended: false, - videoItag: String(item.itag), - width: item.width, - height: item.height, - fps: item.fps, - videoCodec: item.codec ?? undefined, - format: container.toLowerCase(), - qualityHint: pickVideoQuality(item.height, item.width), - }; - }); - - const audios = [...(stream.audioStreams ?? [])] - .sort((left, right) => (right.bitrate ?? 0) - (left.bitrate ?? 0)) - .map((item, index) => { - const locale = item.audioLocale || item.quality || "default"; - const codec = item.codec ?? "audio"; - const container = parseContainer(item.mimeType); - const bitrate = item.bitrate ?? 0; - return { - id: `audio-${item.itag}-${index}`, - mode: "audio" as const, - label: `${item.bitrate ? `${Math.round(item.bitrate)} kbps` : "Audio"} ${container}`, - detail: `${locale} · ${codec} · itag ${item.itag}`, - size: formatBytes(item.contentLength), - recommended: false, - audioItag: String(item.itag), - bitrate, - audioCodec: item.codec ?? undefined, - format: container.toLowerCase(), - qualityHint: pickAudioQuality(bitrate), - }; - }); - - if (videos.length > 0) { - const recommendedIndex = pickRecommendedVideoIndex(videos); - videos[recommendedIndex] = { ...videos[recommendedIndex], recommended: true }; - } - - if (audios.length > 0) { - const recommendedAudio = audios.findIndex( - (audio) => (audio.bitrate ?? 0) >= 160 && (audio.bitrate ?? 0) < 256, - ); - const index = recommendedAudio >= 0 ? recommendedAudio : 0; - audios[index] = { ...audios[index], recommended: true }; - } - - const merged = [...videos, ...audios]; - return merged; -} - -export function buildDownloaderCreatePayload( - videoUrl: string, - option: DownloadOption, -): DownloaderCreateJobRequest { - const mode = option.mode as DownloaderMode; - const format = option.format.length > 0 ? option.format : mode === "video" ? "mp4" : "mp3"; - return { - url: videoUrl, - options: { - mode, - quality: option.qualityHint, - format, - videoItag: option.videoItag, - audioItag: option.audioItag, - height: option.height, - fps: option.fps, - videoCodec: option.videoCodec, - audioCodec: option.audioCodec, - bitrate: option.bitrate, - allowQualityFallback: false, - sponsorBlock: false, - sponsorBlockCategories: ["sponsor", "intro"], - thumbnailOnly: false, - subtitles: { - enabled: false, - auto: false, - embed: false, - languages: ["en"], - format: "srt", - }, - }, - }; -} diff --git a/apps/web/src/components/download-sheet-picker.tsx b/apps/web/src/components/download-sheet-picker.tsx deleted file mode 100644 index 33244ec8..00000000 --- a/apps/web/src/components/download-sheet-picker.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useMemo, useState } from "react"; -import { DownloadModeButton } from "./download-mode-button"; -import { DownloadOptionButton } from "./download-option-button"; -import type { DownloadMode, DownloadOption } from "./download-options"; -import { buildSimpleChoices } from "./download-simple-choices"; - -type Props = { - mode: DownloadMode; - options: DownloadOption[]; - selectedId: string; - onSelect: (id: string) => void; - onMode: (next: DownloadMode) => void; -}; - -export function DownloadSheetPicker({ mode, options, selectedId, onSelect, onMode }: Props) { - const [showAllFormats, setShowAllFormats] = useState(false); - const modeOptions = options.filter((option) => option.mode === mode); - const simpleChoices = useMemo(() => buildSimpleChoices(modeOptions, mode), [modeOptions, mode]); - const selected = modeOptions.find((option) => option.id === selectedId) ?? modeOptions[0]; - - return ( - <> -
- onMode("video")} - label="Video" - /> - onMode("audio")} - label="Audio" - /> -
- {!showAllFormats && ( -
- {simpleChoices.map((choice) => ( - - ))} -
- )} - {showAllFormats && ( -
- {modeOptions.map((option, index) => ( - onSelect(option.id)} - index={index} - compact - /> - ))} -
- )} - {modeOptions.length > simpleChoices.length && ( - - )} - - ); -} diff --git a/apps/web/src/components/download-sheet.tsx b/apps/web/src/components/download-sheet.tsx deleted file mode 100644 index 5d5e59a6..00000000 --- a/apps/web/src/components/download-sheet.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useMemo, useState } from "react"; -import { useArtifactDownloadOnDone } from "../hooks/use-artifact-download-on-done"; -import { useDownloaderJob } from "../hooks/use-downloader-job"; -import { useOverlayLock } from "../hooks/use-overlay-lock"; -import { useSmoothDismiss } from "../hooks/use-smooth-dismiss"; -import { deleteDownloaderJob } from "../lib/api-downloader"; -import type { VideoStream } from "../types/stream"; -import { - buildDownloaderCreatePayload, - buildDownloadOptions, - type DownloadMode, -} from "./download-options"; -import { DownloadSheetPicker } from "./download-sheet-picker"; -import { DownloaderJobFeedback } from "./downloader-job-feedback"; - -type Props = { - stream: VideoStream; - onClose: () => void; - onDone: (message: string) => void; -}; - -export function DownloadSheet({ stream, onClose, onDone }: Props) { - useOverlayLock(true); - const { isClosing, dismiss } = useSmoothDismiss({ onClose }); - const downloader = useDownloaderJob(); - const { - isDone, - jobId, - isQueued, - isRunning, - errorText, - isFailed, - openArtifact, - reset, - start, - canUseIosShareFlow, - cancelJob, - isCancelling, - } = downloader; - const isBusy = isQueued || isRunning; - const [artifactError, setArtifactError] = useState(null); - const [clearPending, setClearPending] = useState(false); - const options = useMemo(() => buildDownloadOptions(stream), [stream]); - const [mode, setMode] = useState("video"); - const [selectedId, setSelectedId] = useState( - options.find((option) => option.recommended)?.id ?? options[0]?.id ?? "", - ); - const selected = options.find((option) => option.id === selectedId) ?? options[0]; - - const completion = useArtifactDownloadOnDone({ - isDone, - jobId, - selectedLabel: selected?.label ?? "file", - openArtifact, - autoStart: !canUseIosShareFlow, - preferShare: canUseIosShareFlow, - onDone, - onDismiss: dismiss, - reset, - onArtifactError: setArtifactError, - }); - const requiresManualArtifactTap = isDone && canUseIosShareFlow; - const showWorkingState = isBusy || completion.isCompleting; - - function selectMode(next: DownloadMode) { - setMode(next); - const modeOptions = options.filter((option) => option.mode === next); - setSelectedId(modeOptions.find((option) => option.recommended)?.id ?? modeOptions[0]?.id ?? ""); - } - - function startDownload() { - if (!selected) return; - setArtifactError(null); - start(buildDownloaderCreatePayload(stream.id, selected)); - } - - async function clearJob() { - if (!jobId) return reset(); - setClearPending(true); - try { - await deleteDownloaderJob(jobId); - reset(); - setArtifactError(null); - } catch (error) { - setArtifactError(error instanceof Error ? error.message : "Failed to clear download job"); - } - setClearPending(false); - } - - return ( -
- -
- {!showWorkingState && !requiresManualArtifactTap && ( - <> - - - - )} - {requiresManualArtifactTap && ( -
-

- File is ready. Tap below to open it in iOS and save to Files. -

- -
- )} - void clearJob()} - /> -
-
-
- ); -} diff --git a/apps/web/src/components/download-simple-choices.ts b/apps/web/src/components/download-simple-choices.ts deleted file mode 100644 index d49f19f4..00000000 --- a/apps/web/src/components/download-simple-choices.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { DownloadMode, DownloadOption } from "./download-options"; - -type SimpleChoice = { - id: string; - title: string; - option: DownloadOption; -}; - -function effectiveVideoHeight(option: DownloadOption): number { - if (typeof option.height !== "number" || option.height <= 0) return 0; - if (typeof option.width !== "number" || option.width <= 0) return option.height; - return Math.min(option.height, option.width); -} - -export function buildSimpleChoices(options: DownloadOption[], mode: DownloadMode): SimpleChoice[] { - if (options.length === 0) return []; - const best = options[0]; - const small = - mode === "video" - ? (options.find((option) => effectiveVideoHeight(option) === 360) ?? - [...options].reverse().find((option) => effectiveVideoHeight(option) >= 360) ?? - options[options.length - 1]) - : options[options.length - 1]; - const balanced = - options.find((option) => option.recommended) ?? options[Math.floor(options.length / 2)]; - const balancedHeight = effectiveVideoHeight(balanced); - const balancedTitle = - mode === "video" && balancedHeight === 1080 - ? "Recommended (1080p)" - : mode === "video" && balancedHeight === 720 - ? "Recommended (720p)" - : mode === "audio" - ? "Recommended" - : "Balanced"; - const raw = [ - { id: best.id, title: mode === "video" ? "Best quality" : "Best sound", option: best }, - { id: balanced.id, title: balancedTitle, option: balanced }, - { id: small.id, title: mode === "video" ? "Small size (360p)" : "Small size", option: small }, - ]; - const unique = new Map(); - raw.forEach((choice) => { - if (!unique.has(choice.id)) unique.set(choice.id, choice); - }); - return Array.from(unique.values()); -} diff --git a/apps/web/src/components/downloader-job-feedback.tsx b/apps/web/src/components/downloader-job-feedback.tsx deleted file mode 100644 index 99c61ccb..00000000 --- a/apps/web/src/components/downloader-job-feedback.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { - DOWNLOADER_STEPS, - downloaderProgressValue, - downloaderStageIndex, - downloaderStatusLabel, - downloaderStatusMessage, - isCancelledDownloaderJob, - isFailedDownloaderJob, - shouldShowDownloaderProgress, -} from "../lib/downloader-display"; -import { DOWNLOADER_INSUFFICIENT_STORAGE_CODE } from "../lib/downloader-errors"; -import type { - DownloaderJobStage, - DownloaderJobStatus, - DownloaderResolvedSelection, -} from "../types/downloader"; -import { DownloaderStorageError } from "./downloader-storage-error"; - -type Props = { - status: DownloaderJobStatus | null; - stage: DownloaderJobStage | null; - progressPercent: number | null; - resolved: DownloaderResolvedSelection | null; - errorCode: string | null; - errorText: string | null; - immersive?: boolean; - forceWaiting?: boolean; - canCancel?: boolean; - cancelPending?: boolean; - onCancel?: () => void; - canClear?: boolean; - clearPending?: boolean; - onClear?: () => void; -}; - -function formatResolved(resolved: DownloaderResolvedSelection | null): string | null { - if (!resolved) return null; - const quality = - typeof resolved.height === "number" - ? `${resolved.height}p` - : typeof resolved.bitrate === "number" - ? `${Math.round(resolved.bitrate)} kbps` - : null; - const container = - typeof resolved.container === "string" && resolved.container.length > 0 - ? resolved.container.toUpperCase() - : null; - if (!quality && !container) return null; - return [quality, container].filter((item) => item !== null).join(" "); -} - -function exactUnavailableMessage(resolved: DownloaderResolvedSelection | null): string { - if (typeof resolved?.height === "number") { - return `Requested ${resolved.height}p is unavailable. Pick another format.`; - } - return "Selected format is unavailable. Pick another format."; -} - -export function DownloaderJobFeedback({ - status, - stage, - progressPercent, - resolved, - errorCode, - errorText, - immersive = false, - forceWaiting = false, - canCancel = false, - cancelPending = false, - onCancel, - canClear = false, - clearPending = false, - onClear, -}: Props) { - const resolvedLabel = formatResolved(resolved); - const visibleError = - errorCode === "exact_selection_unavailable" - ? exactUnavailableMessage(resolved) - : (errorText ?? null); - const cancelled = isCancelledDownloaderJob(status, stage, errorCode); - const failed = isFailedDownloaderJob(status, stage, errorCode); - const label = downloaderStatusLabel(status, stage, errorCode, forceWaiting); - const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting); - const insufficientStorage = errorCode === DOWNLOADER_INSUFFICIENT_STORAGE_CODE; - const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting); - const activeStep = downloaderStageIndex(status, stage); - const showCancel = canCancel && !cancelled && !failed && typeof onCancel === "function"; - const showClear = canClear && typeof onClear === "function"; - const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled; - const showPercent = typeof progressPercent === "number" && status === "running"; - - if (!status && !forceWaiting && !resolvedLabel && !visibleError) return null; - - return ( -
-
-
-

{label}

-

- {message} -

-
-
- {showPercent && ( - {Math.round(progress)}% - )} - {showCancel && ( - - )} - {showClear && ( - - )} -
-
- {insufficientStorage && } - {showProgress && ( -
-
-
- )} - {status === "running" && ( -
- {DOWNLOADER_STEPS.map((step, index) => ( -
- - {step} -
- ))} -
- )} - {resolvedLabel && !failed && ( -

Selected: {resolvedLabel}

- )} -
- ); -} diff --git a/apps/web/src/components/downloader-storage-error.tsx b/apps/web/src/components/downloader-storage-error.tsx deleted file mode 100644 index a3f251c3..00000000 --- a/apps/web/src/components/downloader-storage-error.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { DOWNLOADER_INSUFFICIENT_STORAGE_HELP } from "../lib/downloader-errors"; - -export function DownloaderStorageError() { - return ( -
- -

- {DOWNLOADER_INSUFFICIENT_STORAGE_HELP} -

-
- ); -} diff --git a/apps/web/src/components/external-link-modal.tsx b/apps/web/src/components/external-link-modal.tsx deleted file mode 100644 index 282ffb3e..00000000 --- a/apps/web/src/components/external-link-modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect } from "react"; -import { createPortal } from "react-dom"; - -type Props = { - url: string; - onConfirm: () => void; - onCancel: () => void; -}; - -export function ExternalLinkModal({ url, onConfirm, onCancel }: Props) { - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onCancel(); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = prev; - document.removeEventListener("keydown", onKeyDown); - }; - }, [onCancel]); - - return createPortal( - <> -
-
-
- -

This link will open in a new tab:

-

- {url} -

-
-
- - -
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/family-list-empty-state.tsx b/apps/web/src/components/family-list-empty-state.tsx deleted file mode 100644 index a5e4bb85..00000000 --- a/apps/web/src/components/family-list-empty-state.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { useAuth } from "../hooks/use-auth"; - -type Props = { - title?: string; - description?: string; - showSettingsAction?: boolean; -}; - -export function FamilyListEmptyState({ - title = "Nothing from the family list yet", - description = "Add trusted channels so this page can stay focused on videos you picked for your family.", - showSettingsAction = true, -}: Props) { - const { canGlobalBlock } = useAuth(); - const showAdminAction = showSettingsAction && canGlobalBlock; - return ( -
-
- - Family list - -
-

{title}

-

{description}

-
- {showAdminAction && ( - - Open allow list - - )} -
-
- ); -} diff --git a/apps/web/src/components/flag-icon.tsx b/apps/web/src/components/flag-icon.tsx deleted file mode 100644 index 0db4f97c..00000000 --- a/apps/web/src/components/flag-icon.tsx +++ /dev/null @@ -1,24 +0,0 @@ -function toFlagEmoji(code: string): string | null { - const normalized = code.trim().toUpperCase(); - if (!/^[A-Z]{2}$/.test(normalized)) return null; - const first = normalized.codePointAt(0); - const second = normalized.codePointAt(1); - if (first === undefined || second === undefined) return null; - const base = 127397; - return String.fromCodePoint(first + base, second + base); -} - -type FlagIconProps = { - code: string; - className?: string; -}; - -export function FlagIcon({ code, className }: FlagIconProps) { - const flag = toFlagEmoji(code); - if (!flag) return null; - return ( - - {flag} - - ); -} diff --git a/apps/web/src/components/format-selector.tsx b/apps/web/src/components/format-selector.tsx deleted file mode 100644 index 33c0553b..00000000 --- a/apps/web/src/components/format-selector.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import type * as dashjs from "dashjs"; -import { useRef } from "react"; -import { useDashPlayerSnapshot } from "../lib/dash-player-store"; -import { dashTrackGroups, maxTrackHeight, selectDashTrack } from "../lib/dash-video"; -import { activeFamily, type CodecFamily, codecFamily, groupByFamily } from "../lib/quality-utils"; -import { - maxSabrCodecHeight, - sabrCodecOptions, - selectSabrCodec, -} from "../lib/sabr-quality-selection"; -import type { MenuInstance } from "../lib/vidstack"; -import { - DefaultMenuButton, - DefaultMenuRadioGroup, - Menu, - useVideoQualityOptions, -} from "../lib/vidstack"; -import { useSabrQualityStore } from "../stores/sabr-quality-store"; - -const FORMAT_ORDER: CodecFamily[] = ["H.264", "VP9", "AV1"]; -const MENU_ITEMS_CLASS = - "vds-menu-items overflow-y-auto overscroll-y-contain pr-0.5 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent"; -const QUALITY_OPTIONS = { sort: "descending" } as const; - -function isCodecFamily(value: string): value is CodecFamily { - return value === "H.264" || value === "VP9" || value === "AV1"; -} - -function formatLabel(family: CodecFamily, track: dashjs.MediaInfo): string { - const maxHeight = maxTrackHeight(track); - return maxHeight > 0 ? `${family} ${maxHeight}p` : family; -} - -export function FormatSelector() { - const menuRef = useRef(null); - const { player, selectedVideoTrack } = useDashPlayerSnapshot(); - const options = useVideoQualityOptions(QUALITY_OPTIONS); - const sabrStreamId = useSabrQualityStore((state) => state.streamId); - const sabrOptions = useSabrQualityStore((state) => state.options); - const sabrSelectedItag = useSabrQualityStore((state) => state.selectedItag); - const selectSabrQuality = useSabrQualityStore((state) => state.selectQuality); - - if (sabrStreamId && sabrOptions.length > 0) { - const streamId = sabrStreamId; - const selected = - sabrOptions.find((option) => option.itag === sabrSelectedItag) ?? sabrOptions[0]; - const codecs = sabrCodecOptions(sabrOptions); - if (codecs.length <= 1) return null; - function onSabrChange(value: string) { - if (!isCodecFamily(value)) return; - const next = selectSabrCodec(sabrOptions, selected, value); - if (!next) return; - selectSabrQuality(streamId, next.itag); - menuRef.current?.close(); - } - return ( - - - - ({ - label: `${codec} ${maxSabrCodecHeight(sabrOptions, codec)}p`, - value: codec, - }))} - onChange={onSabrChange} - /> - - - ); - } - - const videoOptions = options.filter((o) => o.quality !== null); - const dashGroups = player ? dashTrackGroups(player) : null; - - if (player && dashGroups && dashGroups.size > 1) { - const dashPlayer = player; - const current = codecFamily( - selectedVideoTrack?.codec ?? player.getCurrentTrackFor("video")?.codec ?? null, - ); - const selected = current ?? FORMAT_ORDER.find((family) => dashGroups.has(family)); - const availableOptions = FORMAT_ORDER.flatMap((family) => { - const track = dashGroups.get(family); - if (!track) return []; - return [{ label: formatLabel(family, track), value: family }]; - }); - - if (!selected) return null; - - function onDashChange(value: string) { - if (!isCodecFamily(value)) return; - const track = dashGroups?.get(value); - if (!track) return; - selectDashTrack(dashPlayer, track); - menuRef.current?.close(); - } - - return ( - - - - - - - ); - } - - const groups = groupByFamily(videoOptions); - const selected = activeFamily(videoOptions) ?? FORMAT_ORDER.find((family) => groups.has(family)); - const availableOptions = FORMAT_ORDER.filter((family) => groups.has(family)).map((family) => ({ - label: family, - value: family, - })); - - if (groups.size <= 1) return null; - if (!selected) return null; - - function onChange(value: string) { - if (!isCodecFamily(value)) return; - groups.get(value)?.select(); - menuRef.current?.close(); - } - - return ( - - - - - - - ); -} diff --git a/apps/web/src/components/fragment-boundary-seeker.tsx b/apps/web/src/components/fragment-boundary-seeker.tsx deleted file mode 100644 index a5b96b80..00000000 --- a/apps/web/src/components/fragment-boundary-seeker.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect } from "react"; -import { snapToFragmentBoundary } from "../lib/fragment-boundary-seek"; -import { isWebKitBrowser } from "../lib/ios-device"; -import { useMediaPlayer } from "../lib/vidstack"; - -export function FragmentBoundarySeeker({ intervalSeconds }: { intervalSeconds?: number }) { - const player = useMediaPlayer(); - - useEffect(() => { - if (!intervalSeconds || !isWebKitBrowser()) return; - const root = player?.el; - if (!root) return; - const media = root.querySelector("video,audio"); - if (!media) return; - let adjusting = false; - - const handleSeeking = () => { - if (adjusting) return; - const target = snapToFragmentBoundary(media.currentTime, intervalSeconds); - if (Math.abs(target - media.currentTime) < 0.05) return; - adjusting = true; - media.currentTime = target; - queueMicrotask(() => { - adjusting = false; - }); - }; - - media.addEventListener("seeking", handleSeeking); - return () => media.removeEventListener("seeking", handleSeeking); - }, [intervalSeconds, player]); - - return null; -} diff --git a/apps/web/src/components/guest-disabled-screen.tsx b/apps/web/src/components/guest-disabled-screen.tsx deleted file mode 100644 index 2a4c5907..00000000 --- a/apps/web/src/components/guest-disabled-screen.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { AuthBackdrop } from "./auth-backdrop"; - -export function GuestDisabledScreen() { - return ( - - -
-

- Guest mode disabled -

-

- The admin has disabled guest access. -

-

- To use this TypeType instance, create an account or sign in with an existing one. -

-
-
- - Create account - - - Sign in - -
-
- ); -} diff --git a/apps/web/src/components/hide-everything-intro.tsx b/apps/web/src/components/hide-everything-intro.tsx deleted file mode 100644 index 1d5ebbcc..00000000 --- a/apps/web/src/components/hide-everything-intro.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from "react"; - -const HOLDS = [3000, 2700, 2100]; -const FADE = 430; - -type Props = { - onDone: () => void; - onRelax: () => void; -}; - -export function HideEverythingIntro({ onDone, onRelax }: Props) { - const [phase, setPhase] = useState(0); - const [shown, setShown] = useState(true); - - useEffect(() => { - if (phase === HOLDS.length - 1) { - onRelax(); - } - const hold = HOLDS[phase]; - const hide = window.setTimeout(() => setShown(false), hold); - const next = window.setTimeout(() => { - if (phase + 1 < HOLDS.length) { - setShown(true); - setPhase(phase + 1); - } else { - onDone(); - } - }, hold + FADE); - return () => { - window.clearTimeout(hide); - window.clearTimeout(next); - }; - }, [phase, onDone, onRelax]); - - return ( -
-
- {phase === 0 && ( -
- - Fine! - - - You want to hide everything? - -
- )} - {phase === 1 && ( - - Do you know how much time I spent building all of this?! - - )} - {phase === 2 && ( - - Go and relax - - )} -
-
- ); -} diff --git a/apps/web/src/components/hide-everything-scene.tsx b/apps/web/src/components/hide-everything-scene.tsx deleted file mode 100644 index 8a233398..00000000 --- a/apps/web/src/components/hide-everything-scene.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { PixelBird } from "./pixel-bird"; -import { PixelCloud } from "./pixel-cloud"; -import { PixelCouchSeat } from "./pixel-couch-seat"; -import { PixelGround } from "./pixel-ground"; -import { PixelMountain } from "./pixel-mountain"; -import { PixelTvStand } from "./pixel-tv-stand"; -import { PixelWaterfall } from "./pixel-waterfall"; - -const FAR_PALETTE = { - snow: "#f5fbff", - light: "#6faec0", - mid: "#3f8798", - dark: "#22535d", - accent: "#aab3ae", -}; - -const MAIN_PALETTE = { - snow: "#ffffff", - light: "#3f8da0", - mid: "#2c7180", - dark: "#1d4648", - accent: "#a8b1ac", -}; - -const FRONT_PALETTE = { - snow: "#ffffff", - light: "#2f7788", - mid: "#1f5c68", - dark: "#153e42", - accent: "#0f3334", -}; - -function CloudBand() { - return ( -
-
-
-
-
-
-
-
-
-
-
- ); -} - -export function HideEverythingScene() { - return ( -
-
-
-
- -
- - - - - - - - - - - - -
- ); -} diff --git a/apps/web/src/components/history-calendar-header.tsx b/apps/web/src/components/history-calendar-header.tsx deleted file mode 100644 index 67ce22f0..00000000 --- a/apps/web/src/components/history-calendar-header.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from "react"; -import { ChevronLeft, ChevronRight } from "./history-calendar-icons"; - -const MONTH_NAMES = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -const CURRENT_YEAR = new Date().getFullYear(); -const YEARS = Array.from({ length: CURRENT_YEAR - 1999 }, (_, i) => CURRENT_YEAR - i); - -type Props = { - month: number; - year: number; - canGoNext: boolean; - onPrevMonth: () => void; - onNextMonth: () => void; - onMonthChange: (month: number) => void; - onYearChange: (year: number) => void; -}; - -export function CalendarHeader({ - month, - year, - canGoNext, - onPrevMonth, - onNextMonth, - onMonthChange, - onYearChange, -}: Props) { - const [dropdown, setDropdown] = useState<"month" | "year" | null>(null); - - const toggle = (which: "month" | "year") => setDropdown(dropdown === which ? null : which); - - const close = () => setDropdown(null); - - const handleMonthSelect = (m: number) => { - onMonthChange(m); - close(); - }; - const handleYearSelect = (y: number) => { - onYearChange(y); - close(); - }; - - return ( -
- {dropdown && ( - - -
-
- - {dropdown === "month" && ( -
- {MONTH_NAMES.map((name, m) => ( - - ))} -
- )} -
- -
- - {dropdown === "year" && ( -
- {YEARS.map((y) => ( - - ))} -
- )} -
-
- - -
- ); -} diff --git a/apps/web/src/components/history-calendar-icons.tsx b/apps/web/src/components/history-calendar-icons.tsx deleted file mode 100644 index c5f2fe44..00000000 --- a/apps/web/src/components/history-calendar-icons.tsx +++ /dev/null @@ -1,37 +0,0 @@ -export function ChevronLeft() { - return ( - - - - ); -} - -export function ChevronRight() { - return ( - - - - ); -} diff --git a/apps/web/src/components/history-calendar.tsx b/apps/web/src/components/history-calendar.tsx deleted file mode 100644 index 5355b752..00000000 --- a/apps/web/src/components/history-calendar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState } from "react"; -import { CalendarHeader } from "./history-calendar-header"; - -type Props = { - selected: Date | null; - onSelect: (date: Date) => void; -}; - -const DAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; - -function startOfDay(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()); -} - -function isSameDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -export function HistoryCalendar({ selected, onSelect }: Props) { - const today = startOfDay(new Date()); - - const initialMonth = selected - ? new Date(selected.getFullYear(), selected.getMonth(), 1) - : new Date(today.getFullYear(), today.getMonth(), 1); - - const [viewMonth, setViewMonth] = useState(initialMonth); - - const year = viewMonth.getFullYear(); - const month = viewMonth.getMonth(); - - const firstDayOffset = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - - const canGoNext = - new Date(year, month + 1, 1) <= new Date(today.getFullYear(), today.getMonth(), 1); - - const prevMonth = () => setViewMonth(new Date(year, month - 1, 1)); - const nextMonth = () => { - if (canGoNext) setViewMonth(new Date(year, month + 1, 1)); - }; - - const handleMonthChange = (m: number) => { - setViewMonth(new Date(year, m, 1)); - }; - - const handleYearChange = (y: number) => { - const clampedMonth = - y === today.getFullYear() && month > today.getMonth() ? today.getMonth() : month; - setViewMonth(new Date(y, clampedMonth, 1)); - }; - - const emptySlots = Array.from({ length: firstDayOffset }, (_, i) => `slot-${i}`); - const days = Array.from({ length: daysInMonth }, (_, i) => i + 1); - - return ( -
- - -
- {DAY_LABELS.map((d) => ( -
- {d} -
- ))} - - {emptySlots.map((key) => ( -
- ))} - - {days.map((day) => { - const cellDate = new Date(year, month, day); - const isFuture = cellDate > today; - const isSelected = selected !== null && isSameDay(cellDate, selected); - const isToday = isSameDay(cellDate, today); - - return ( - - ); - })} -
-
- ); -} diff --git a/apps/web/src/components/history-card.tsx b/apps/web/src/components/history-card.tsx deleted file mode 100644 index ee085149..00000000 --- a/apps/web/src/components/history-card.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { useDeArrowBranding } from "../hooks/use-dearrow"; -import { formatDuration } from "../lib/format"; -import { proxyImage } from "../lib/proxy"; -import { isVideoWatched } from "../lib/watch-progress"; -import { watchRouteSearch } from "../lib/watch-url"; -import type { HistoryItem } from "../types/user"; -import { ChannelRouteLink } from "./channel-route-link"; -import { HistoryChannelAvatar } from "./history-channel-avatar"; -import { VideoProgressBar } from "./video-progress-bar"; -import { WatchedBadge } from "./watched-badge"; - -function XIcon() { - return ( - - - - - ); -} - -type HistoryCardProps = { item: HistoryItem; onRemove: () => void; index: number }; - -function formatWatchedAt(timestamp: number): string { - return new Date(timestamp).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -export function HistoryCard({ item, onRemove, index }: HistoryCardProps) { - const delay = Math.min(index * 45, 270); - const watched = isVideoWatched(item.progress, item.duration); - const branding = useDeArrowBranding( - item.url, - item.title, - proxyImage(item.thumbnail), - item.duration, - ); - - return ( -
- -
- {branding.title} - {item.duration > 0 && ( - - {formatDuration(item.duration)} - - )} - {watched && ( - - - - )} - - -
- -
- {item.channelUrl ? ( - - - - ) : ( - - - - )} -
- -

- {branding.title} -

- - {item.channelUrl ? ( - - {item.channelName} - - ) : ( -

{item.channelName}

- )} -

- Watched {formatWatchedAt(item.watchedAt)} -

-
-
-
- ); -} diff --git a/apps/web/src/components/history-channel-avatar.tsx b/apps/web/src/components/history-channel-avatar.tsx deleted file mode 100644 index 4ac35dab..00000000 --- a/apps/web/src/components/history-channel-avatar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { proxyImage } from "../lib/proxy"; -import type { HistoryItem } from "../types/user"; -import { ChannelAvatar } from "./channel-avatar"; - -type HistoryChannelAvatarProps = { - item: HistoryItem; - className: string; -}; - -export function HistoryChannelAvatar({ item, className }: HistoryChannelAvatarProps) { - return ( - - ); -} diff --git a/apps/web/src/components/history-filter.tsx b/apps/web/src/components/history-filter.tsx deleted file mode 100644 index 6109c590..00000000 --- a/apps/web/src/components/history-filter.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useState } from "react"; -import { HistoryCalendar } from "./history-calendar"; - -export type FilterState = - | { kind: "preset"; value: "today" | "week" | "month" } - | { kind: "date"; date: Date }; - -type Props = { - searchQuery: string; - onSearchChange: (value: string) => void; - filter: FilterState | null; - onFilterChange: (value: FilterState | null) => void; - resultCount: number; - canClearHistory: boolean; - onClearHistory: () => void; -}; - -const PRESET_OPTIONS = [ - { label: "Today", value: "today" as const }, - { label: "This week", value: "week" as const }, - { label: "This month", value: "month" as const }, -]; - -function SearchIcon() { - return ( - - - - - ); -} - -function formatDate(date: Date): string { - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); -} - -export function HistoryFilter({ - searchQuery, - onSearchChange, - filter, - onFilterChange, - resultCount, - canClearHistory, - onClearHistory, -}: Props) { - const [calendarOpen, setCalendarOpen] = useState(false); - - const hasActiveFilter = searchQuery.length > 0 || filter !== null; - - const isPresetActive = (value: string) => filter?.kind === "preset" && filter.value === value; - - const selectedDate = filter?.kind === "date" ? filter.date : null; - - const olderActive = filter?.kind === "date" || calendarOpen; - - const handlePreset = (value: "today" | "week" | "month") => { - setCalendarOpen(false); - onFilterChange(isPresetActive(value) ? null : { kind: "preset", value }); - }; - - const handleOlderToggle = () => { - if (calendarOpen) { - setCalendarOpen(false); - if (filter?.kind === "date") onFilterChange(null); - } else { - setCalendarOpen(true); - onFilterChange(null); - } - }; - - const handleDateSelect = (date: Date) => { - onFilterChange({ kind: "date", date }); - }; - - const handleClear = () => { - onSearchChange(""); - onFilterChange(null); - setCalendarOpen(false); - }; - - return ( - - ); -} diff --git a/apps/web/src/components/home-fallback-section.tsx b/apps/web/src/components/home-fallback-section.tsx deleted file mode 100644 index cbc153bc..00000000 --- a/apps/web/src/components/home-fallback-section.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useMemo } from "react"; -import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import { useSubscriptionFeed } from "../hooks/use-subscription-feed"; -import { useSubscriptions } from "../hooks/use-subscriptions"; -import { ScrollSentinel } from "./scroll-sentinel"; -import { VideoGrid } from "./video-grid"; -import { VideoGridSkeleton } from "./video-grid-skeleton"; - -function FeedSection() { - const { streams, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = - useSubscriptionFeed(); - const { filter } = useBlockedFilter(); - const filtered = useMemo(() => filter(streams), [filter, streams]); - if (isLoading) return ; - return ( - <> - - {isFetchingNextPage && } - - - ); -} - -export function HomeFallbackSection() { - const { query } = useSubscriptions(); - const hasSubs = (query.data ?? []).length > 0; - if (query.isLoading) return ; - if (hasSubs) return ; - return ( -
-

No subscriptions yet

-

- Subscribe to channels to unlock a personalized home feed. -

-
- ); -} diff --git a/apps/web/src/components/home-recommendations-section.tsx b/apps/web/src/components/home-recommendations-section.tsx deleted file mode 100644 index 7554c90d..00000000 --- a/apps/web/src/components/home-recommendations-section.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from "react"; -import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import { useHomeRecommendations } from "../hooks/use-home-recommendations"; -import { useSettings } from "../hooks/use-settings"; -import { FamilyListEmptyState } from "./family-list-empty-state"; -import { HomeFallbackSection } from "./home-fallback-section"; -import { ScrollSentinel } from "./scroll-sentinel"; -import { VideoGrid } from "./video-grid"; -import { VideoGridSkeleton } from "./video-grid-skeleton"; - -export function HomeRecommendationsSection() { - const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = - useHomeRecommendations(); - const { settings } = useSettings(); - const { filter } = useBlockedFilter(); - const filtered = useMemo(() => filter(streams), [filter, streams]); - - if (isLoading) return ; - if (isError || filtered.length === 0) { - if (settings.accessMode === "allow_list") { - return ; - } - return ; - } - return ( - <> - - {isFetchingNextPage && } - - - ); -} diff --git a/apps/web/src/components/import-mascot-loop.tsx b/apps/web/src/components/import-mascot-loop.tsx deleted file mode 100644 index 1154b107..00000000 --- a/apps/web/src/components/import-mascot-loop.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState } from "react"; - -type Props = { - primarySrc: string; - secondarySrc: string; - className: string; - intervalMs?: number; -}; - -export function ImportMascotLoop({ - primarySrc, - secondarySrc, - className, - intervalMs = 3600, -}: Props) { - const [showPrimary, setShowPrimary] = useState(true); - - useEffect(() => { - const timer = window.setInterval(() => setShowPrimary((v) => !v), intervalMs); - return () => window.clearInterval(timer); - }, [intervalMs]); - - const src = showPrimary ? primarySrc : secondarySrc; - - return
- , - document.body, - ); -} diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx deleted file mode 100644 index d94b2355..00000000 --- a/apps/web/src/components/player-defaults.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useEffect, useRef } from "react"; -import { - useAudioOptions, - useMediaPlayer, - useMediaState, - useVideoQualityOptions, -} from "../lib/vidstack"; -import { includesOriginal, normalizeLanguageTag } from "./player-language"; - -const QUALITY_OPTIONS = { sort: "descending" } as const; - -type PlayerDefaultsProps = { - defaultQuality?: string; - defaultAudioLanguage?: string; - preferOriginalLanguage?: boolean; - requireOriginalLanguage?: boolean; - onOriginalLanguageUnavailable?: () => void; - originalAudioTrackId?: string | null; - preferredDefaultAudioTrackId?: string | null; - originalAudioLocale?: string | null; - subtitlesEnabled?: boolean; - defaultSubtitleLanguage?: string; -}; - -function qualityLabelHeight(label: string): number | null { - const match = label.match(/(\d+)/); - if (!match) return null; - const height = Number(match[1]); - return Number.isFinite(height) ? height : null; -} - -export function PlayerDefaults({ - defaultQuality, - defaultAudioLanguage, - preferOriginalLanguage, - requireOriginalLanguage, - onOriginalLanguageUnavailable, - originalAudioTrackId, - preferredDefaultAudioTrackId, - originalAudioLocale, - subtitlesEnabled, - defaultSubtitleLanguage, -}: PlayerDefaultsProps) { - const canPlay = useMediaState("canPlay"); - const player = useMediaPlayer(); - const qualityOptions = useVideoQualityOptions(QUALITY_OPTIONS); - const audioOptions = useAudioOptions(); - const textTracks = useMediaState("textTracks"); - const qualityApplied = useRef(false); - const appliedAudioOptionsCount = useRef(0); - const subtitleApplied = useRef(false); - const originalMissingNotified = useRef(false); - - const preferredTag = normalizeLanguageTag(defaultAudioLanguage); - const originalTag = normalizeLanguageTag(originalAudioLocale); - - useEffect(() => { - if (!canPlay || qualityApplied.current || !defaultQuality) return; - const media = player?.el?.querySelector("video,audio"); - const currentTime = media && Number.isFinite(media.currentTime) ? media.currentTime : 0; - if (currentTime > 1.5) { - qualityApplied.current = true; - return; - } - const defaultHeight = qualityLabelHeight(defaultQuality); - const exactMatch = qualityOptions.find((o) => o.label === defaultQuality); - const heightMatch = qualityOptions.find( - (o) => defaultHeight !== null && o.quality?.height === defaultHeight, - ); - const match = exactMatch ?? heightMatch; - if (!match) return; - match.select(); - qualityApplied.current = true; - }, [canPlay, qualityOptions, defaultQuality, player]); - - useEffect(() => { - const forceOriginal = requireOriginalLanguage || preferOriginalLanguage; - const canReapplyOriginalSelection = - forceOriginal && - appliedAudioOptionsCount.current > 0 && - audioOptions.length > appliedAudioOptionsCount.current; - const selectedTrackId = audioOptions.find((option) => option.selected)?.track.id; - const expectedTrackId = forceOriginal - ? (originalAudioTrackId ?? preferredDefaultAudioTrackId ?? undefined) - : (preferredDefaultAudioTrackId ?? undefined); - const shouldFixMismatchedSelection = - expectedTrackId !== undefined && - expectedTrackId !== null && - selectedTrackId !== undefined && - selectedTrackId !== expectedTrackId; - if ( - audioOptions.length === 0 || - (appliedAudioOptionsCount.current > 0 && - !canReapplyOriginalSelection && - !shouldFixMismatchedSelection) - ) { - return; - } - - let match = forceOriginal - ? (audioOptions.find((option) => option.track.id === originalAudioTrackId) ?? - audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ?? - audioOptions.find((option) => includesOriginal(option.label)) ?? - audioOptions.find((option) => normalizeLanguageTag(option.track.language) === originalTag)) - : (audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ?? - audioOptions.find((option) => option.track.id === originalAudioTrackId)); - - const missingOriginalByContract = forceOriginal && originalAudioTrackId === null; - const missingOriginalByHeuristic = - forceOriginal && originalAudioTrackId === undefined && !match; - if ( - (missingOriginalByContract || missingOriginalByHeuristic) && - !originalMissingNotified.current - ) { - originalMissingNotified.current = true; - onOriginalLanguageUnavailable?.(); - } - - if (!match) { - match = audioOptions.find( - (option) => normalizeLanguageTag(option.track.language) === preferredTag, - ); - } - if (!match) { - match = audioOptions[0]; - } - if (!match || selectedTrackId === match.track.id) return; - - match.select(); - appliedAudioOptionsCount.current = audioOptions.length; - }, [ - audioOptions, - originalAudioTrackId, - preferredDefaultAudioTrackId, - preferOriginalLanguage, - requireOriginalLanguage, - originalTag, - preferredTag, - onOriginalLanguageUnavailable, - ]); - - useEffect(() => { - if (!canPlay || subtitleApplied.current || !subtitlesEnabled) return; - for (const track of textTracks) { - if (track.kind !== "subtitles" && track.kind !== "captions") continue; - if (defaultSubtitleLanguage && track.language !== defaultSubtitleLanguage) continue; - track.setMode("showing"); - subtitleApplied.current = true; - break; - } - }, [canPlay, textTracks, subtitlesEnabled, defaultSubtitleLanguage]); - - return null; -} diff --git a/apps/web/src/components/player-error.tsx b/apps/web/src/components/player-error.tsx deleted file mode 100644 index 21840fac..00000000 --- a/apps/web/src/components/player-error.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useRouter } from "@tanstack/react-router"; -import { useEffect } from "react"; - -type Props = { - onRetry: () => void; -}; - -export function PlayerError({ onRetry }: Props) { - const router = useRouter(); - - useEffect(() => { - document.body.style.overflow = "hidden"; - document.documentElement.style.overflow = "hidden"; - return () => { - document.body.style.overflow = ""; - document.documentElement.style.overflow = ""; - }; - }, []); - - return ( -
- -
-

Playback failed

-

- This video could not be played. The stream may be unavailable or unsupported. -

-
-
- - -
-
- ); -} diff --git a/apps/web/src/components/player-fast-forward-indicator.tsx b/apps/web/src/components/player-fast-forward-indicator.tsx deleted file mode 100644 index 976f912d..00000000 --- a/apps/web/src/components/player-fast-forward-indicator.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Rabbit } from "lucide-react"; - -export function PlayerFastForwardIndicator() { - return ( -
-
- ); -} diff --git a/apps/web/src/components/player-hotkeys-utils.ts b/apps/web/src/components/player-hotkeys-utils.ts deleted file mode 100644 index 111de1d1..00000000 --- a/apps/web/src/components/player-hotkeys-utils.ts +++ /dev/null @@ -1,81 +0,0 @@ -export function isInteractiveTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - return Boolean( - target.closest( - "a, button, input, textarea, select, summary, [contenteditable='true'], [role='button'], [role='menuitem'], [role='slider']", - ), - ); -} - -export function isPlayerSeekShortcutTarget( - target: EventTarget | null, - player: HTMLElement | null, -): boolean { - if (!isInteractiveTarget(target)) return true; - if (!(target instanceof HTMLElement) || !player?.contains(target)) return false; - const slider = target.closest("[role='slider']"); - if (slider && !slider.classList.contains("vds-time-slider")) return false; - return !target.closest( - "input, textarea, select, [contenteditable='true'], [role='menu'], [role='menuitem'], [role='listbox'], [role='option']", - ); -} - -export function clampTime(value: number, duration: number): number { - const max = duration > 0 ? duration : Number.POSITIVE_INFINITY; - return Math.min(max, Math.max(0, value)); -} - -export function keyboardSeekOffset(code: string): number | null { - if (code === "ArrowLeft") return -10; - if (code === "ArrowRight") return 10; - return null; -} - -export type KeyboardSeekTarget = { - position: number; - updatedAt: number; -}; - -export function nextKeyboardSeekTarget( - currentTime: number, - duration: number, - offset: number, - previous: KeyboardSeekTarget, - now: number, -): KeyboardSeekTarget { - const base = now - previous.updatedAt <= 1_000 ? previous.position : currentTime; - return { position: clampTime(base + offset, duration), updatedAt: now }; -} - -export function consumeEvent(event: KeyboardEvent) { - event.preventDefault(); - event.stopImmediatePropagation(); -} - -export function consumePointerEvent(event: PointerEvent) { - event.preventDefault(); - event.stopImmediatePropagation(); -} - -export function isFastForwardPointer(event: PointerEvent): boolean { - if (event.pointerType === "mouse") return event.button === 0; - return event.pointerType === "touch" || event.pointerType === "pen"; -} - -const DRAG_THRESHOLD = 12; - -export function exceededDragThreshold(dx: number, dy: number): boolean { - return Math.abs(dx) >= DRAG_THRESHOLD && Math.abs(dx) > Math.abs(dy); -} - -export function computeScrubTarget( - startTime: number, - dx: number, - width: number, - range: number, - duration: number, -): number { - if (width <= 0) return clampTime(startTime, duration); - return clampTime(startTime + (dx / width) * range, duration); -} diff --git a/apps/web/src/components/player-hotkeys.tsx b/apps/web/src/components/player-hotkeys.tsx deleted file mode 100644 index 173dce19..00000000 --- a/apps/web/src/components/player-hotkeys.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { usePlayerGestures } from "../hooks/use-player-gestures"; -import { usePlayerKeyboard } from "../hooks/use-player-keyboard"; -import { PlayerFastForwardIndicator } from "./player-fast-forward-indicator"; - -export function PlayerHotkeys({ - canSeek, - sabrVideo, -}: { - canSeek: boolean; - sabrVideo: HTMLVideoElement | null; -}) { - const touchHolding = usePlayerGestures(canSeek); - const keyboardHolding = usePlayerKeyboard(canSeek, sabrVideo); - return touchHolding || keyboardHolding ? : null; -} diff --git a/apps/web/src/components/player-internals.tsx b/apps/web/src/components/player-internals.tsx deleted file mode 100644 index fbef721e..00000000 --- a/apps/web/src/components/player-internals.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useEffect, useRef } from "react"; -import { isIosDevice } from "../lib/ios-device"; -import { seekSponsorBlockSegment } from "../lib/sponsorblock-seek"; -import { getSponsorBlockEndTime, getSponsorBlockStartTime } from "../lib/sponsorblock-settings"; -import { - crossedSponsorBlockStart, - emitSponsorBlockSkip, - isSponsorBlockEndSkip, - sponsorBlockSkipTarget, -} from "../lib/sponsorblock-skip"; -import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack"; -import type { SponsorBlockSegmentItem } from "../types/api"; - -export function SeekBridge({ - onSeekReady, -}: { - onSeekReady: (seek: (seconds: number) => void) => void; -}) { - const remote = useMediaRemote(); - const onSeekReadyRef = useRef(onSeekReady); - onSeekReadyRef.current = onSeekReady; - useEffect(() => { - onSeekReadyRef.current((seconds: number) => remote.seek(seconds)); - }, [remote]); - return null; -} - -export function PlayerFocuser() { - const ios = isIosDevice(); - const player = useMediaPlayer(); - const canPlay = useMediaState("canPlay"); - const focused = useRef(false); - useEffect(() => { - if (ios) return; - if (!canPlay || focused.current || !player?.el) return; - focused.current = true; - player.el.focus({ preventScroll: true }); - }, [ios, canPlay, player]); - return null; -} - -export function SponsorBlockSkipper({ - segments, - muteInsteadOfSkip, -}: { - segments: SponsorBlockSegmentItem[]; - muteInsteadOfSkip: boolean; -}) { - const player = useMediaPlayer(); - const remote = useMediaRemote(); - const canPlay = useMediaState("canPlay"); - const activeMuteRef = useRef(null); - const pendingSkipRef = useRef<{ key: string; startTime: number; endTime: number } | null>(null); - const restoreMutedRef = useRef(false); - const previousTimeRef = useRef(null); - useEffect(() => { - if (!canPlay) return; - const root = player?.el; - if (!root) return; - const rootElement = root; - let cleanup: (() => void) | null = null; - - function setMuted(media: HTMLMediaElement, value: boolean) { - media.muted = value; - media.dispatchEvent(new Event("volumechange", { bubbles: true })); - } - - function process(media: HTMLMediaElement) { - const duration = Number.isFinite(media.duration) ? media.duration : 0; - const currentTime = Number.isFinite(media.currentTime) ? media.currentTime : 0; - const previousTime = previousTimeRef.current; - previousTimeRef.current = currentTime; - const pendingSkip = pendingSkipRef.current; - if ( - pendingSkip && - (currentTime >= pendingSkip.endTime - 0.1 || - (currentTime < pendingSkip.startTime && - media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA)) - ) { - pendingSkipRef.current = null; - } - let activeMute: string | null = null; - for (const seg of segments) { - if (seg.action !== "skip") continue; - const startTime = getSponsorBlockStartTime(seg, duration); - const endTime = getSponsorBlockEndTime(seg, duration); - if (currentTime >= startTime && currentTime < endTime) { - if (!muteInsteadOfSkip) { - const key = `${seg.category}:${seg.startTime}:${seg.endTime}`; - if (pendingSkipRef.current?.key === key) break; - const crossedStart = crossedSponsorBlockStart(previousTime, currentTime, startTime); - if (!crossedStart) break; - emitSponsorBlockSkip({ - category: seg.category, - automatic: true, - toEnd: isSponsorBlockEndSkip(endTime, duration), - }); - pendingSkipRef.current = { key, startTime, endTime }; - seekSponsorBlockSegment( - media instanceof HTMLVideoElement ? media : null, - (seconds) => remote.seek(seconds), - sponsorBlockSkipTarget(endTime, duration), - ); - break; - } - activeMute = `${seg.category}:${seg.startTime}`; - if (activeMuteRef.current !== activeMute) { - activeMuteRef.current = activeMute; - restoreMutedRef.current = !media.muted; - } - setMuted(media, true); - break; - } - } - if (muteInsteadOfSkip && !activeMute && activeMuteRef.current) { - if (restoreMutedRef.current) setMuted(media, false); - activeMuteRef.current = null; - restoreMutedRef.current = false; - } - } - - function attach() { - if (cleanup) return true; - const media = rootElement.querySelector("video,audio"); - if (!media) return false; - previousTimeRef.current = null; - const update = () => process(media); - const seek = () => { - previousTimeRef.current = Number.isFinite(media.currentTime) ? media.currentTime : 0; - process(media); - }; - media.addEventListener("timeupdate", update); - media.addEventListener("seeking", seek); - media.addEventListener("durationchange", update); - media.addEventListener("loadedmetadata", update); - update(); - cleanup = () => { - media.removeEventListener("timeupdate", update); - media.removeEventListener("seeking", seek); - media.removeEventListener("durationchange", update); - media.removeEventListener("loadedmetadata", update); - }; - return true; - } - - if (attach()) return () => cleanup?.(); - const observer = new MutationObserver(() => { - if (attach()) observer.disconnect(); - }); - observer.observe(rootElement, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - cleanup?.(); - }; - }, [canPlay, muteInsteadOfSkip, player, segments, remote]); - return null; -} diff --git a/apps/web/src/components/player-language.ts b/apps/web/src/components/player-language.ts deleted file mode 100644 index 08fc13af..00000000 --- a/apps/web/src/components/player-language.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function normalizeLanguageTag(value: string | null | undefined): string { - if (!value) return ""; - const [base] = value.toLowerCase().split("-"); - return base ?? ""; -} - -export function includesOriginal(value: string | undefined): boolean { - if (!value) return false; - return value.toLowerCase().includes("original"); -} diff --git a/apps/web/src/components/player-play-pause-indicator.tsx b/apps/web/src/components/player-play-pause-indicator.tsx deleted file mode 100644 index 37b60454..00000000 --- a/apps/web/src/components/player-play-pause-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Pause, Play } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useMediaState } from "../lib/vidstack"; - -export function PlayerPlayPauseIndicator() { - const paused = useMediaState("paused"); - const canPlay = useMediaState("canPlay"); - const [pulse, setPulse] = useState(0); - const prevPausedRef = useRef(paused); - const readyRef = useRef(false); - - useEffect(() => { - if (!canPlay) return; - if (!readyRef.current) { - readyRef.current = true; - prevPausedRef.current = paused; - return; - } - if (paused === prevPausedRef.current) return; - prevPausedRef.current = paused; - setPulse((n) => n + 1); - }, [paused, canPlay]); - - useEffect(() => { - if (pulse === 0) return; - const timer = window.setTimeout(() => setPulse(0), 500); - return () => window.clearTimeout(timer); - }, [pulse]); - - if (pulse === 0) return null; - const Icon = paused ? Pause : Play; - return ( -
-
-
-
- ); -} diff --git a/apps/web/src/components/player-seeker.tsx b/apps/web/src/components/player-seeker.tsx deleted file mode 100644 index 2bfeaffc..00000000 --- a/apps/web/src/components/player-seeker.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useEffect, useRef } from "react"; -import { recordClientEvent } from "../lib/client-debug-log"; -import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack"; - -function seekable(media: HTMLMediaElement, target: number) { - if (media.readyState === 0 && !Number.isFinite(media.duration)) return false; - if (media.seekable.length === 0) return true; - for (let index = 0; index < media.seekable.length; index += 1) { - if (target >= media.seekable.start(index) && target <= media.seekable.end(index)) return true; - } - return false; -} - -export function PlayerSeeker({ startTime }: { startTime: number }) { - const player = useMediaPlayer(); - const remote = useMediaRemote(); - const canPlay = useMediaState("canPlay"); - const seeked = useRef(false); - const startTimeRef = useRef(startTime); - if (startTimeRef.current !== startTime) { - startTimeRef.current = startTime; - seeked.current = false; - } - - useEffect(() => { - if (startTime <= 0 || seeked.current) return; - const target = startTime / 1000; - const root = player?.el; - let timeout = 0; - let applying = false; - - function seekMedia(media: HTMLMediaElement) { - if (seeked.current || applying) return; - if (!seekable(media, target)) { - recordClientEvent("player.seek_wait", { - targetMs: Math.round(target * 1000), - readyState: media.readyState, - seekableRanges: media.seekable.length, - }); - return; - } - applying = true; - try { - media.currentTime = target; - } catch {} - remote.seek(target); - recordClientEvent("player.seek_apply", { - targetMs: Math.round(target * 1000), - currentMs: Math.round(media.currentTime * 1000), - tag: media.tagName, - }); - timeout = window.setTimeout(() => { - applying = false; - if (Math.abs(media.currentTime - target) <= 1.5 || media.currentTime > target) { - recordClientEvent("player.seek_settled", { - targetMs: Math.round(target * 1000), - currentMs: Math.round(media.currentTime * 1000), - tag: media.tagName, - }); - seeked.current = true; - return; - } - seekMedia(media); - }, 250); - } - - if (canPlay) remote.seek(target); - if (!root) return; - const rootElement = root; - let cleanup: (() => void) | null = null; - - function attach() { - if (cleanup) return true; - const media = rootElement.querySelector("video,audio"); - if (!media) return false; - const seek = () => seekMedia(media); - media.addEventListener("loadedmetadata", seek); - media.addEventListener("durationchange", seek); - media.addEventListener("canplay", seek); - media.addEventListener("progress", seek); - seek(); - cleanup = () => { - window.clearTimeout(timeout); - media.removeEventListener("loadedmetadata", seek); - media.removeEventListener("durationchange", seek); - media.removeEventListener("canplay", seek); - media.removeEventListener("progress", seek); - }; - return true; - } - - if (attach()) return () => cleanup?.(); - const observer = new MutationObserver(() => { - if (attach()) observer.disconnect(); - }); - observer.observe(rootElement, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - cleanup?.(); - }; - }, [canPlay, player, remote, startTime]); - return null; -} diff --git a/apps/web/src/components/player-track-button.tsx b/apps/web/src/components/player-track-button.tsx deleted file mode 100644 index b98c45c4..00000000 --- a/apps/web/src/components/player-track-button.tsx +++ /dev/null @@ -1,27 +0,0 @@ -type Props = { - direction: "previous" | "next"; - onClick?: () => void; -}; - -export function PlayerTrackButton({ direction, onClick }: Props) { - if (!onClick) return null; - const label = direction === "previous" ? "Previous video" : "Next video"; - - return ( - - ); -} diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx deleted file mode 100644 index 2f512023..00000000 --- a/apps/web/src/components/player-volume-control.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { defaultLayoutIcons, MuteButton, useMediaState, VolumeSlider } from "../lib/vidstack"; - -export function PlayerVolumeControl() { - const muted = useMediaState("muted"); - const volume = useMediaState("volume"); - const canSetVolume = useMediaState("canSetVolume"); - const Icon = - muted || volume === 0 - ? defaultLayoutIcons.MuteButton.Mute - : volume < 0.5 - ? defaultLayoutIcons.MuteButton.VolumeLow - : defaultLayoutIcons.MuteButton.VolumeHigh; - - return ( -
- - - - {canSetVolume ? ( - - - - - - - ) : null} -
- ); -} diff --git a/apps/web/src/components/playlist-actions.tsx b/apps/web/src/components/playlist-actions.tsx deleted file mode 100644 index 9f12d6d9..00000000 --- a/apps/web/src/components/playlist-actions.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Play, Shuffle } from "lucide-react"; - -type Props = { - onPlayAll: () => void; - onShuffle: () => void; -}; - -export function PlaylistActions({ onPlayAll, onShuffle }: Props) { - return ( -
- - -
- ); -} diff --git a/apps/web/src/components/playlist-add-dropdown.tsx b/apps/web/src/components/playlist-add-dropdown.tsx deleted file mode 100644 index 4a4030c5..00000000 --- a/apps/web/src/components/playlist-add-dropdown.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { usePlaylists } from "../hooks/use-playlists"; -import type { VideoStream } from "../types/stream"; -import { PlaylistRow } from "./playlist-row"; - -const MARGIN = 8; - -type Props = { - stream: VideoStream; - anchorEl: HTMLElement | null; - onClose: () => void; - onSaved: (label: string) => void; -}; - -export function PlaylistAddDropdown({ stream, anchorEl, onClose, onSaved }: Props) { - const { query, create, addVideo, removeVideo, isInPlaylist } = usePlaylists(); - const playlists = query.data ?? []; - const [newName, setNewName] = useState(""); - const panelRef = useRef(null); - const [panelStyle, setPanelStyle] = useState({ visibility: "hidden" }); - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - const anchorElRef = useRef(anchorEl); - anchorElRef.current = anchorEl; - - useLayoutEffect(() => { - if (!anchorEl || !panelRef.current) return; - const anchor = anchorEl.getBoundingClientRect(); - const panel = panelRef.current.getBoundingClientRect(); - const vw = document.documentElement.clientWidth; - const vh = document.documentElement.clientHeight; - - let left = anchor.right - panel.width; - left = Math.min(left, vw - panel.width - MARGIN); - left = Math.max(MARGIN, left); - - const spaceBelow = vh - anchor.bottom - MARGIN; - const spaceAbove = anchor.top - MARGIN; - let top: number; - if (spaceBelow >= panel.height || spaceBelow >= spaceAbove) { - top = anchor.bottom + MARGIN; - } else { - top = anchor.top - panel.height - MARGIN; - } - top = Math.max(MARGIN, Math.min(top, vh - panel.height - MARGIN)); - - setPanelStyle({ position: "fixed", top, left, visibility: "visible" }); - }, [anchorEl]); - - useEffect(() => { - function onMouseDown(e: MouseEvent) { - const target = e.target as Node; - const outsidePanel = panelRef.current && !panelRef.current.contains(target); - const outsideAnchor = !anchorElRef.current?.contains(target); - if (outsidePanel && outsideAnchor) onCloseRef.current(); - } - function onScroll() { - onCloseRef.current(); - } - window.addEventListener("mousedown", onMouseDown); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => { - window.removeEventListener("mousedown", onMouseDown); - window.removeEventListener("scroll", onScroll); - }; - }, []); - - function handleToggle(playlistId: string) { - const playlist = playlists.find((p) => p.id === playlistId); - if (!playlist) return; - if (isInPlaylist(playlistId, stream.id)) { - removeVideo.mutate({ playlistId, videoUrl: stream.id }); - onSaved(`Removed from ${playlist.name}`); - } else { - addVideo.mutate({ - playlistId, - video: { - url: stream.id, - title: stream.title, - thumbnail: stream.thumbnail, - channelName: stream.channelName, - channelUrl: stream.channelUrl ?? "", - channelAvatar: stream.channelAvatar, - viewCount: stream.views, - duration: stream.duration, - }, - }); - onSaved(`Saved to ${playlist.name}`); - } - } - - function handleCreate() { - const trimmed = newName.trim(); - if (!trimmed) return; - create.mutate(trimmed); - setNewName(""); - onSaved(`Playlist "${trimmed}" created`); - } - - return createPortal( -
-

- Save to playlist -

-
- {playlists.length === 0 && ( -

No playlists yet.

- )} - {playlists.map((playlist) => ( - handleToggle(playlist.id)} - /> - ))} -
-
- setNewName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleCreate()} - placeholder="New playlist..." - className="min-w-0 flex-1 text-xs bg-surface-strong text-fg placeholder-zinc-500 rounded-lg px-2.5 py-1.5 outline-none focus:ring-1 focus:ring-border-strong" - /> - -
-
, - document.body, - ); -} diff --git a/apps/web/src/components/playlist-card.tsx b/apps/web/src/components/playlist-card.tsx deleted file mode 100644 index 8404ae14..00000000 --- a/apps/web/src/components/playlist-card.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import type { PlaylistItem } from "../types/playlist"; - -type Props = { - playlist: PlaylistItem; - selectionMode?: boolean; - selected?: boolean; - onToggleSelect?: () => void; - onDeleteRequest: () => void; -}; - -function TrashIcon() { - return ( - - - - - ); -} - -function EmptyIcon() { - return ( - - - - - - - ); -} - -function ThumbnailContent({ playlist }: { playlist: PlaylistItem }) { - const thumbnail = playlist.videos?.[0]?.thumbnail; - const count = playlist.videoCount ?? playlist.videos?.length ?? 0; - const label = `${count} video${count !== 1 ? "s" : ""}`; - return ( -
- {thumbnail ? ( - {playlist.name} - ) : ( -
- -
- )} - - {label} - -
- ); -} - -export function PlaylistCard({ - playlist, - selectionMode, - selected, - onToggleSelect, - onDeleteRequest, -}: Props) { - const count = playlist.videoCount ?? playlist.videos?.length ?? 0; - const label = `${count} video${count !== 1 ? "s" : ""}`; - - return ( -
-
- {selectionMode ? ( - - ) : ( - - - - )} -
-
- selectionMode && e.preventDefault()} - > -

- {playlist.name} -

-

{label}

- - {!selectionMode && ( - - )} -
-
- ); -} diff --git a/apps/web/src/components/playlist-create-modal.tsx b/apps/web/src/components/playlist-create-modal.tsx deleted file mode 100644 index c09388f6..00000000 --- a/apps/web/src/components/playlist-create-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; - -type Props = { - onConfirm: (name: string) => void; - onCancel: () => void; -}; - -export function PlaylistCreateModal({ onConfirm, onCancel }: Props) { - const [name, setName] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - inputRef.current?.focus(); - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onCancel(); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = prev; - document.removeEventListener("keydown", onKeyDown); - }; - }, [onCancel]); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const trimmed = name.trim(); - if (!trimmed) return; - onConfirm(trimmed); - } - - return createPortal( - <> -
-
-

- New playlist -

-
- setName(e.target.value)} - placeholder="Playlist name..." - className="bg-surface-strong text-fg placeholder-zinc-500 rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-border-strong w-full" - /> -
- - -
-
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/playlist-grid.tsx b/apps/web/src/components/playlist-grid.tsx deleted file mode 100644 index 0d548aa2..00000000 --- a/apps/web/src/components/playlist-grid.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { type DragEvent, useState } from "react"; -import type { PlaylistVideoItem } from "../types/user"; -import { PlaylistVideoRow } from "./playlist-video-row"; - -type Props = { - videos: PlaylistVideoItem[]; - reorderable: boolean; - listId: string; - onRemove: (video: PlaylistVideoItem) => void; - onReorder: (order: string[]) => void; -}; - -export function PlaylistGrid({ videos, reorderable, listId, onRemove, onReorder }: Props) { - const [dragIndex, setDragIndex] = useState(null); - const [overIndex, setOverIndex] = useState(null); - - function handleDragStart(event: DragEvent, index: number) { - setDragIndex(index); - event.dataTransfer.effectAllowed = "move"; - const card = (event.currentTarget as HTMLElement).closest("[data-pl-card]"); - if (card instanceof HTMLElement) event.dataTransfer.setDragImage(card, 20, 20); - } - - function handleDrop(targetIndex: number) { - if (dragIndex !== null && dragIndex !== targetIndex) { - const next = [...videos]; - const [moved] = next.splice(dragIndex, 1); - next.splice(targetIndex, 0, moved); - onReorder(next.map((video) => video.url)); - } - setDragIndex(null); - setOverIndex(null); - } - - return ( -
    - {videos.map((video, index) => ( -
  • { - event.preventDefault(); - setOverIndex(index); - } - : undefined - } - onDrop={reorderable ? () => handleDrop(index) : undefined} - onDragEnd={ - reorderable - ? () => { - setDragIndex(null); - setOverIndex(null); - } - : undefined - } - > - onRemove(video)} - reorderable={reorderable} - listId={listId} - onDragStart={(event) => handleDragStart(event, index)} - /> -
  • - ))} -
- ); -} diff --git a/apps/web/src/components/playlist-rename-modal.tsx b/apps/web/src/components/playlist-rename-modal.tsx deleted file mode 100644 index a5ced64d..00000000 --- a/apps/web/src/components/playlist-rename-modal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; - -type Props = { - currentName: string; - onConfirm: (name: string) => void; - onCancel: () => void; -}; - -export function PlaylistRenameModal({ currentName, onConfirm, onCancel }: Props) { - const [name, setName] = useState(currentName); - const inputRef = useRef(null); - - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - inputRef.current?.focus(); - inputRef.current?.select(); - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onCancel(); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = prev; - document.removeEventListener("keydown", onKeyDown); - }; - }, [onCancel]); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const trimmed = name.trim(); - if (!trimmed || trimmed === currentName) return; - onConfirm(trimmed); - } - - return createPortal( - <> -
-
-

- Rename playlist -

-
- setName(e.target.value)} - placeholder="Playlist name..." - className="bg-surface-strong text-fg placeholder-zinc-500 rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-border-strong w-full" - /> -
- - -
-
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/playlist-row.tsx b/apps/web/src/components/playlist-row.tsx deleted file mode 100644 index 00a96cce..00000000 --- a/apps/web/src/components/playlist-row.tsx +++ /dev/null @@ -1,44 +0,0 @@ -function CheckIcon() { - return ( - - - - ); -} - -type RowProps = { - label: string; - checked: boolean; - onToggle: () => void; -}; - -export function PlaylistRow({ label, checked, onToggle }: RowProps) { - return ( - - ); -} diff --git a/apps/web/src/components/playlist-sort-menu.tsx b/apps/web/src/components/playlist-sort-menu.tsx deleted file mode 100644 index 1e3e22f5..00000000 --- a/apps/web/src/components/playlist-sort-menu.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ChevronDown } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { PLAYLIST_SORT_OPTIONS, type PlaylistSortMode } from "../lib/playlist-sort"; - -const MARGIN = 8; - -type Props = { - value: PlaylistSortMode; - onChange: (value: PlaylistSortMode) => void; -}; - -export function PlaylistSortMenu({ value, onChange }: Props) { - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - const panelRef = useRef(null); - const [panelStyle, setPanelStyle] = useState({ visibility: "hidden" }); - const current = PLAYLIST_SORT_OPTIONS.find((option) => option.value === value); - - useLayoutEffect(() => { - if (!open || !anchorRef.current || !panelRef.current) return; - const anchor = anchorRef.current.getBoundingClientRect(); - const panel = panelRef.current.getBoundingClientRect(); - const vw = document.documentElement.clientWidth; - const vh = document.documentElement.clientHeight; - let left = Math.min(anchor.right - panel.width, vw - panel.width - MARGIN); - left = Math.max(MARGIN, left); - const spaceBelow = vh - anchor.bottom - MARGIN; - let top = - spaceBelow >= panel.height ? anchor.bottom + MARGIN : anchor.top - panel.height - MARGIN; - top = Math.max(MARGIN, Math.min(top, vh - panel.height - MARGIN)); - setPanelStyle({ position: "fixed", top, left, visibility: "visible" }); - }, [open]); - - useEffect(() => { - if (!open) return; - function onMouseDown(event: MouseEvent) { - const target = event.target as Node; - if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; - setOpen(false); - } - window.addEventListener("mousedown", onMouseDown); - return () => window.removeEventListener("mousedown", onMouseDown); - }, [open]); - - return ( - <> - - {open && - createPortal( -
- {PLAYLIST_SORT_OPTIONS.map((option) => ( - - ))} -
, - document.body, - )} - - ); -} diff --git a/apps/web/src/components/playlist-video-row.tsx b/apps/web/src/components/playlist-video-row.tsx deleted file mode 100644 index f07d7518..00000000 --- a/apps/web/src/components/playlist-video-row.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { GripVertical } from "lucide-react"; -import type { DragEvent } from "react"; -import { useDeArrowBranding } from "../hooks/use-dearrow"; -import { formatDuration, formatViews } from "../lib/format"; -import { proxyImage } from "../lib/proxy"; -import { isVideoWatched } from "../lib/watch-progress"; -import { watchRouteSearch } from "../lib/watch-url"; -import type { VideoStream } from "../types/stream"; -import type { PlaylistVideoItem } from "../types/user"; -import { ChannelRouteLink } from "./channel-route-link"; -import { VideoCardFeedbackMenu } from "./video-card-feedback-menu"; -import { VideoProgressBar } from "./video-progress-bar"; -import { WatchedBadge } from "./watched-badge"; - -function XIcon() { - return ( - - - - - ); -} - -type Props = { - video: PlaylistVideoItem; - onRemove: () => void; - reorderable?: boolean; - listId?: string; - onDragStart?: (event: DragEvent) => void; -}; - -export function PlaylistVideoRow({ video, onRemove, reorderable, listId, onDragStart }: Props) { - const rawThumbnail = video.thumbnail.trim(); - const fallbackThumbnail = rawThumbnail.length > 0 ? proxyImage(rawThumbnail) : ""; - const branding = useDeArrowBranding(video.url, video.title, fallbackThumbnail, video.duration); - const thumbnail = branding.thumbnail || null; - const watched = video.watched || isVideoWatched(video.watchPosition, video.duration); - const rawChannelName = video.channelName?.trim() ?? ""; - const rawChannelUrl = video.channelUrl?.trim() ?? ""; - const rawChannelAvatar = video.channelAvatar?.trim() ?? ""; - const rawViews = video.viewCount ?? 0; - const channelName = rawChannelName; - const channelUrl = rawChannelUrl; - const channelAvatar = rawChannelAvatar; - const views = rawViews; - const menuStream: VideoStream = { - id: video.url, - title: video.title, - thumbnail: thumbnail ?? "", - rawThumbnail, - rawChannelAvatar: rawChannelAvatar, - channelName, - channelUrl: channelUrl || undefined, - channelAvatar, - views, - duration: video.duration, - }; - const watchSearch = listId - ? { ...watchRouteSearch(video.url), list: listId } - : watchRouteSearch(video.url); - - return ( -
- -
- {thumbnail && ( - {branding.title} - )} - {watched && ( - - - - )} - {video.duration > 0 && ( - - {formatDuration(video.duration)} - - )} - - - {reorderable && ( - - )} -
- - -

- {branding.title} -

- -
-
- {channelName.length > 0 && - (channelUrl.length > 0 ? ( - - {channelName} - - ) : ( -

{channelName}

- ))} - {views > 0 &&

{formatViews(views)}

} -
- -
-
- ); -} diff --git a/apps/web/src/components/playlists-empty-state.tsx b/apps/web/src/components/playlists-empty-state.tsx deleted file mode 100644 index a7c7e8aa..00000000 --- a/apps/web/src/components/playlists-empty-state.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function PlaylistsEmptyState() { - return ( -
-

No playlists yet.

-

Use the New playlist button to get started.

-
- ); -} diff --git a/apps/web/src/components/playlists-page-header.tsx b/apps/web/src/components/playlists-page-header.tsx deleted file mode 100644 index 91cd20c2..00000000 --- a/apps/web/src/components/playlists-page-header.tsx +++ /dev/null @@ -1,63 +0,0 @@ -type Props = { - selectionMode: boolean; - selectedCount: number; - canSelect: boolean; - onSelect: () => void; - onCancel: () => void; - onDelete: () => void; - onCreate: () => void; -}; - -export function PlaylistsPageHeader({ - selectionMode, - selectedCount, - canSelect, - onSelect, - onCancel, - onDelete, - onCreate, -}: Props) { - const base = "rounded-lg px-3 py-2 text-sm transition-colors"; - return ( -
-

Playlists

-
- {selectionMode ? ( - <> - {selectedCount} selected - - - - ) : ( - <> - {canSelect && ( - - )} - - - )} -
-
- ); -} diff --git a/apps/web/src/components/podcast-card.tsx b/apps/web/src/components/podcast-card.tsx deleted file mode 100644 index ec1716f4..00000000 --- a/apps/web/src/components/podcast-card.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { proxyImage } from "../lib/proxy"; -import type { PodcastItem } from "../types/api"; - -type Props = { - podcast: PodcastItem; - channelAvatar?: string; -}; - -export function PodcastCard({ podcast, channelAvatar }: Props) { - const thumbnail = proxyImage(podcast.thumbnailUrl); - const count = podcast.streamCount === 1 ? "1 episode" : `${podcast.streamCount} episodes`; - - return ( - -
- {podcast.title} - {channelAvatar && ( - - )} -
-
-

- {podcast.title} -

-

{podcast.uploaderName}

-

{count}

-
- - ); -} diff --git a/apps/web/src/components/profile-avatar-settings.tsx b/apps/web/src/components/profile-avatar-settings.tsx deleted file mode 100644 index 2b746cf5..00000000 --- a/apps/web/src/components/profile-avatar-settings.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useMemo, useRef, useState } from "react"; -import { useAuth } from "../hooks/use-auth"; -import { useAvatar } from "../hooks/use-avatar"; -import { getOpenMojiUrl } from "../lib/openmoji"; -import { OPENMOJI_CATALOG } from "../lib/openmoji-catalog"; -import { CustomAvatarUpload } from "./custom-avatar-upload"; -import { Toast } from "./toast"; - -const CARD = "bg-surface rounded-xl border border-border overflow-hidden divide-y divide-border"; -const SCROLL_STEP = 220; - -function normalizeTerm(value: string): string { - return value.trim().toLowerCase(); -} - -function ArrowIcon({ right }: { right: boolean }) { - const d = right ? "m9 6 6 6-6 6" : "m15 6-6 6 6 6"; - return ( - - {right ? "Right arrow" : "Left arrow"} - - - ); -} - -export function ProfileAvatarSettings() { - const { me } = useAuth(); - const { emoji, clear } = useAvatar(); - const listRef = useRef(null); - const [search, setSearch] = useState(""); - const [toast, setToast] = useState(null); - const busy = emoji.isPending || clear.isPending; - const selectedCode = me?.avatarType === "emoji" ? me.avatarCode : null; - - const filtered = useMemo(() => { - const term = normalizeTerm(search); - if (term.length === 0) return OPENMOJI_CATALOG; - return OPENMOJI_CATALOG.filter((item) => item.label.includes(term) || item.code.includes(term)); - }, [search]); - - function scroll(direction: "left" | "right") { - const next = direction === "right" ? SCROLL_STEP : -SCROLL_STEP; - listRef.current?.scrollBy({ left: next, behavior: "smooth" }); - } - - if (!me || me.id.startsWith("guest:")) return null; - - return ( -
-

Avatar

-
- -
-
-

- Emojis from{" "} - - OpenMoji - -

- setSearch(event.target.value)} - placeholder="Search emojis" - className="h-8 w-40 rounded-md border border-border-strong bg-app px-2 text-xs text-fg" - /> -
-
- -
-
- {filtered.map((item) => { - const selected = selectedCode === item.code; - return ( - - ); - })} -
-
- -
-
-
- -
-
- -
- ); -} diff --git a/apps/web/src/components/profile-avatar.tsx b/apps/web/src/components/profile-avatar.tsx deleted file mode 100644 index 4e286368..00000000 --- a/apps/web/src/components/profile-avatar.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { toApiUrl } from "../lib/env"; -import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji"; -import type { AuthMe } from "../types/auth"; - -type ProfileAvatarProps = { - me: AuthMe; - className: string; - plain?: boolean; -}; - -export function ProfileAvatar({ me, className, plain = false }: ProfileAvatarProps) { - const seed = `${me.id}:${me.publicUsername ?? "profile"}`; - const avatarUrl = - me.avatarType === "emoji" && typeof me.avatarCode === "string" && me.avatarCode.length > 0 - ? getOpenMojiUrl(me.avatarCode) - : me.avatarUrl - ? toApiUrl(me.avatarUrl) - : getOpenMojiUrl(pickOpenMojiCode(seed)); - - return ( -
- {me.publicUsername -
- ); -} diff --git a/apps/web/src/components/provider-brand-icon.tsx b/apps/web/src/components/provider-brand-icon.tsx deleted file mode 100644 index 1eef07e4..00000000 --- a/apps/web/src/components/provider-brand-icon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { brandIconColor, oidcProviderIcon } from "../lib/oidc-provider-icon"; -import { ServiceIcon } from "./service-icon"; - -function GoogleGlyph() { - return ( - - - - - - - ); -} - -export function ProviderBrandIcon({ providerName }: { providerName: string | null }) { - if ((providerName ?? "").toLowerCase().includes("google")) return ; - const icon = oidcProviderIcon(providerName); - return ; -} diff --git a/apps/web/src/components/public-playlist-card.tsx b/apps/web/src/components/public-playlist-card.tsx deleted file mode 100644 index fbb0dddd..00000000 --- a/apps/web/src/components/public-playlist-card.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { proxyImage } from "../lib/proxy"; -import type { PublicPlaylistInfo } from "../types/playlist"; - -type Props = { - playlist: PublicPlaylistInfo; -}; - -export function PublicPlaylistCard({ playlist }: Props) { - const count = playlist.streamCount === 1 ? "1 video" : `${playlist.streamCount} videos`; - - return ( - -
- {playlist.title} -
- {count} -
-
-
-

- {playlist.title} -

-

{playlist.uploaderName}

-
- - ); -} diff --git a/apps/web/src/components/public-playlist-header.tsx b/apps/web/src/components/public-playlist-header.tsx deleted file mode 100644 index 92bcda93..00000000 --- a/apps/web/src/components/public-playlist-header.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { proxyImage } from "../lib/proxy"; -import type { PublicPlaylistInfo } from "../types/playlist"; - -type Props = { - info: PublicPlaylistInfo; -}; - -export function PublicPlaylistHeader({ info }: Props) { - const count = info.streamCount; - return ( -
- {info.thumbnailUrl && ( - - )} -
-

{info.title}

- {info.uploaderName &&

{info.uploaderName}

} -

- {count} video{count !== 1 ? "s" : ""} -

-
-
- ); -} diff --git a/apps/web/src/components/quality-selector.tsx b/apps/web/src/components/quality-selector.tsx deleted file mode 100644 index 5aae9b10..00000000 --- a/apps/web/src/components/quality-selector.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import type * as dashjs from "dashjs"; -import { useRef } from "react"; -import { useDashPlayerSnapshot } from "../lib/dash-player-store"; -import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video"; -import { sabrResolutionOptions } from "../lib/sabr-quality-selection"; -import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack"; -import { - ClipIcon, - DefaultMenuButton, - DefaultMenuRadioGroup, - Menu, - useVideoQualityOptions, -} from "../lib/vidstack"; -import { useSabrQualityStore } from "../stores/sabr-quality-store"; - -const qualityIcon: DefaultLayoutIcon = (props) => ; -const MENU_ITEMS_CLASS = - "vds-menu-items max-h-[44svh] overflow-y-auto overscroll-y-contain pr-0.5 md:max-h-72 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent"; -const QUALITY_OPTIONS = { sort: "descending" } as const; - -type QualityOption = ReturnType[number]; - -function qualityValue(option: QualityOption): string { - return String(option.quality?.height ?? option.label); -} - -function collectResolutionOptions(options: QualityOption[]): QualityOption[] { - const grouped = new Map(); - for (const option of options) { - if (option.quality === null) continue; - const value = qualityValue(option); - const current = grouped.get(value); - if (!current || option.selected) grouped.set(value, option); - } - return [...grouped.values()]; -} - -function activeDashTrack( - player: dashjs.MediaPlayerClass, - selectedVideoTrack: dashjs.MediaInfo | null, -): dashjs.MediaInfo | null { - return selectedVideoTrack ?? player.getCurrentTrackFor("video"); -} - -export function QualitySelector() { - const menuRef = useRef(null); - const { player, selectedVideoTrack } = useDashPlayerSnapshot(); - const options = useVideoQualityOptions(QUALITY_OPTIONS); - const sabrStreamId = useSabrQualityStore((state) => state.streamId); - const sabrOptions = useSabrQualityStore((state) => state.options); - const sabrSelectedItag = useSabrQualityStore((state) => state.selectedItag); - const selectSabrQuality = useSabrQualityStore((state) => state.selectQuality); - - if (sabrStreamId && sabrOptions.length > 0) { - const streamId = sabrStreamId; - const selected = - sabrOptions.find((option) => option.itag === sabrSelectedItag) ?? sabrOptions[0]; - const resolutionOptions = sabrResolutionOptions(sabrOptions, selected); - if (resolutionOptions.length <= 1) return null; - function onSabrChange(value: string) { - const itag = Number(value); - if (!Number.isInteger(itag)) return; - selectSabrQuality(streamId, itag); - menuRef.current?.close(); - } - return ( - - - - ({ - label: option.label, - value: String(option.itag), - }))} - onChange={onSabrChange} - /> - - - ); - } - - const dashTrack = player ? activeDashTrack(player, selectedVideoTrack) : null; - if (player && dashTrack) { - const dashPlayer = player; - const activeTrack = dashTrack; - const selectedHeight = selectedDashHeight(dashPlayer, activeTrack); - const dashOptions = dashQualityOptions(activeTrack, selectedHeight); - const selected = dashOptions.find((option) => option.selected) ?? dashOptions[0]; - - if (dashOptions.length > 1 && selected) { - function onDashChange(value: string) { - const height = Number(value); - if (!Number.isFinite(height)) return; - selectDashTrack(dashPlayer, activeTrack, height); - menuRef.current?.close(); - } - - return ( - - - - ({ - label: option.label, - value: option.value, - }))} - onChange={onDashChange} - /> - - - ); - } - } - - const videoOptions = options.filter((o) => o.quality !== null); - const filteredOptions = collectResolutionOptions(videoOptions); - const selected = filteredOptions.find((o) => o.selected) ?? filteredOptions[0]; - const radioOptions = filteredOptions.map((o) => ({ label: o.label, value: qualityValue(o) })); - - if (filteredOptions.length <= 1) return null; - if (filteredOptions.every((o) => (o.quality?.height ?? 0) === 0)) return null; - - if (!selected) return null; - const current = selected.label; - - function onChange(value: string) { - filteredOptions.find((o) => qualityValue(o) === value)?.select(); - menuRef.current?.close(); - } - - return ( - - - - - - - ); -} diff --git a/apps/web/src/components/related-card-skeleton.tsx b/apps/web/src/components/related-card-skeleton.tsx deleted file mode 100644 index 592c9770..00000000 --- a/apps/web/src/components/related-card-skeleton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export function RelatedCardSkeleton() { - return ( -
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/related-card.tsx b/apps/web/src/components/related-card.tsx deleted file mode 100644 index 313fb35d..00000000 --- a/apps/web/src/components/related-card.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { memo } from "react"; -import { useClientLocale } from "../hooks/use-client-locale"; -import { useDeArrowBranding } from "../hooks/use-dearrow"; -import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; -import { watchRouteSearch } from "../lib/watch-url"; -import { useWatchNavigationStore } from "../stores/watch-navigation-store"; -import type { VideoStream } from "../types/stream"; -import { ChannelAvatar } from "./channel-avatar"; -import { ChannelRouteLink } from "./channel-route-link"; -import { VideoCardFeedbackMenu } from "./video-card-feedback-menu"; -import { VideoStatusBadge } from "./video-status-badge"; -import { VerifiedBadgeIcon } from "./watch-icons"; - -type Props = { - stream: VideoStream; - relatedStreams?: VideoStream[]; -}; - -function RelatedCardComponent({ stream, relatedStreams }: Props) { - const locale = useClientLocale(); - const setNavigation = useWatchNavigationStore((state) => state.setNavigation); - const { title, thumbnail } = useDeArrowBranding( - stream.id, - stream.title, - stream.thumbnail, - stream.duration, - ); - const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale); - const metadata = [formatViews(stream.views), publishedText].filter(Boolean).join(" · "); - - return ( -
- setNavigation(stream, relatedStreams)} - > - {title} - {stream.requiresMembership && ( - - Members only - - )} - {(stream.isLive || stream.isPostLive) && ( - - - - )} - {!stream.isLive && stream.duration > 0 && ( - - {formatDuration(stream.duration)} - - )} - -
- setNavigation(stream, relatedStreams)} - > - {title} - - {stream.channelUrl ? ( - - - - {stream.channelName} - {stream.uploaderVerified && } - - - ) : ( -
- - - {stream.channelName} - {stream.uploaderVerified && } - -
- )} -

{metadata}

-
- -
- ); -} - -export const RelatedCard = memo(RelatedCardComponent); diff --git a/apps/web/src/components/related-videos.tsx b/apps/web/src/components/related-videos.tsx deleted file mode 100644 index 07fb4d09..00000000 --- a/apps/web/src/components/related-videos.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from "react"; -import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import type { VideoStream } from "../types/stream"; -import { AutoplayToggle } from "./autoplay-toggle"; -import { RelatedCard } from "./related-card"; -import { RelatedCardSkeleton } from "./related-card-skeleton"; - -const SKELETON_KEYS = Array.from({ length: 4 }, (_, i) => `rs-${i}`); - -type Props = { - streams: VideoStream[]; - isLoading?: boolean; -}; - -function uniqueStreams(streams: VideoStream[]): VideoStream[] { - const seen = new Set(); - const unique: VideoStream[] = []; - for (const stream of streams) { - if (seen.has(stream.id)) continue; - seen.add(stream.id); - unique.push(stream); - } - return unique; -} - -export function RelatedVideos({ streams, isLoading = false }: Props) { - const { filter } = useBlockedFilter(); - const visible = useMemo(() => uniqueStreams(filter(streams)), [filter, streams]); - return ( -
- - {isLoading - ? SKELETON_KEYS.map((k) => ) - : visible.map((stream, index) => ( -
- -
- ))} -
- ); -} diff --git a/apps/web/src/components/report-bug-modal.tsx b/apps/web/src/components/report-bug-modal.tsx deleted file mode 100644 index 2c361e45..00000000 --- a/apps/web/src/components/report-bug-modal.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { useBugReport } from "../hooks/use-bug-report"; -import type { BugReportCategory, PlayerStateContext } from "../types/bug-report"; - -type Props = { - videoUrl?: string | null; - playerState?: PlayerStateContext | null; - onClose: () => void; -}; - -const CATEGORIES: { value: BugReportCategory; label: string }[] = [ - { value: "player", label: "Player" }, - { value: "audio_language", label: "Audio Language" }, - { value: "subtitles", label: "Subtitles" }, - { value: "ui", label: "Interface" }, - { value: "functionality", label: "Functionality" }, -]; - -export function ReportBugModal({ videoUrl, playerState, onClose }: Props) { - const [category, setCategory] = useState("player"); - const [description, setDescription] = useState(""); - const mutation = useBugReport(); - - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = prev; - document.removeEventListener("keydown", onKeyDown); - }; - }, [onClose]); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (description.trim().length === 0) return; - mutation.mutate( - { category, description: description.trim(), videoUrl, playerState }, - { onSuccess: onClose }, - ); - } - - const isSubmitting = mutation.isPending; - const hasError = mutation.isError; - - return createPortal( - <> -
-
-

- Report a Bug -

- -
-
- - -
- -
- -