fix oauth package name
This commit is contained in:
		
							
								
								
									
										123
									
								
								.gitea/workflows/cloudflare-pages.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								.gitea/workflows/cloudflare-pages.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
				
			|||||||
 | 
					name: Deploy to Cloudflare Pages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - main
 | 
				
			||||||
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					env:
 | 
				
			||||||
 | 
					  OAUTH_DIR: oauth
 | 
				
			||||||
 | 
					  KEEP_DEPLOYMENTS: 5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  deploy:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    permissions:
 | 
				
			||||||
 | 
					      contents: read
 | 
				
			||||||
 | 
					      deployments: write
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Setup Node.js
 | 
				
			||||||
 | 
					        uses: actions/setup-node@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: '21'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Install dependencies
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          cd ${{ env.OAUTH_DIR }}
 | 
				
			||||||
 | 
					          npm install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build OAuth app
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          cd ${{ env.OAUTH_DIR }}
 | 
				
			||||||
 | 
					          NODE_ENV=production npm run build
 | 
				
			||||||
 | 
					      - name: Copy OAuth build to static
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          rm -rf my-blog/static/assets
 | 
				
			||||||
 | 
					          cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
 | 
				
			||||||
 | 
					          cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					      - name: Cache ailog binary
 | 
				
			||||||
 | 
					        uses: actions/cache@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ./bin
 | 
				
			||||||
 | 
					          key: ailog-bin-${{ runner.os }}
 | 
				
			||||||
 | 
					          restore-keys: |
 | 
				
			||||||
 | 
					            ailog-bin-${{ runner.os }}
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					      - name: Setup ailog binary
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          # Get expected version from Cargo.toml
 | 
				
			||||||
 | 
					          EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
 | 
				
			||||||
 | 
					          echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          # Check current binary version if exists
 | 
				
			||||||
 | 
					          if [ -f "./bin/ailog" ]; then
 | 
				
			||||||
 | 
					            CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
 | 
				
			||||||
 | 
					            echo "Current binary version: $CURRENT_VERSION"
 | 
				
			||||||
 | 
					          else
 | 
				
			||||||
 | 
					            CURRENT_VERSION="none"
 | 
				
			||||||
 | 
					            echo "No binary found"
 | 
				
			||||||
 | 
					          fi
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          # Check OS
 | 
				
			||||||
 | 
					          OS="${{ runner.os }}"
 | 
				
			||||||
 | 
					          echo "Runner OS: $OS"
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          # Use pre-packaged binary if version matches or extract from tar.gz
 | 
				
			||||||
 | 
					          if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
 | 
				
			||||||
 | 
					            echo "Binary is up to date"
 | 
				
			||||||
 | 
					            chmod +x ./bin/ailog
 | 
				
			||||||
 | 
					          elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
 | 
				
			||||||
 | 
					            echo "Extracting ailog from pre-packaged tar.gz..."
 | 
				
			||||||
 | 
					            cd bin
 | 
				
			||||||
 | 
					            tar -xzf ailog-linux-x86_64.tar.gz
 | 
				
			||||||
 | 
					            chmod +x ailog
 | 
				
			||||||
 | 
					            cd ..
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            # Verify extracted version
 | 
				
			||||||
 | 
					            EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
 | 
				
			||||||
 | 
					            echo "Extracted binary version: $EXTRACTED_VERSION"
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
 | 
				
			||||||
 | 
					              echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
 | 
				
			||||||
 | 
					            fi
 | 
				
			||||||
 | 
					          else
 | 
				
			||||||
 | 
					            echo "Error: No suitable binary found for OS: $OS"
 | 
				
			||||||
 | 
					            exit 1
 | 
				
			||||||
 | 
					          fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build site with ailog
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          cd my-blog
 | 
				
			||||||
 | 
					          ../bin/ailog build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: List public directory
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          ls -la my-blog/public/
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					      - name: Deploy to Cloudflare Pages
 | 
				
			||||||
 | 
					        uses: cloudflare/pages-action@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
 | 
				
			||||||
 | 
					          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
 | 
				
			||||||
 | 
					          projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
 | 
				
			||||||
 | 
					          directory: my-blog/public
 | 
				
			||||||
 | 
					          wranglerVersion: '3'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cleanup:
 | 
				
			||||||
 | 
					    needs: deploy
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    if: success()
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Cleanup old deployments
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          curl -X PATCH \
 | 
				
			||||||
 | 
					            "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
 | 
				
			||||||
 | 
					            -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
				
			||||||
 | 
					            -H "Content-Type: application/json" \
 | 
				
			||||||
 | 
					            -d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
 | 
				
			||||||
							
								
								
									
										193
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,193 @@
 | 
				
			|||||||
 | 
					name: Release
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    tags:
 | 
				
			||||||
 | 
					      - 'v*'
 | 
				
			||||||
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					    inputs:
 | 
				
			||||||
 | 
					      tag:
 | 
				
			||||||
 | 
					        description: 'Release tag (e.g., v1.0.0)'
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					        default: 'v0.1.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					permissions:
 | 
				
			||||||
 | 
					  contents: write
 | 
				
			||||||
 | 
					  actions: read
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					env:
 | 
				
			||||||
 | 
					  CARGO_TERM_COLOR: always
 | 
				
			||||||
 | 
					  OPENSSL_STATIC: true
 | 
				
			||||||
 | 
					  OPENSSL_VENDOR: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    name: Build ${{ matrix.target }}
 | 
				
			||||||
 | 
					    runs-on: ${{ matrix.os }}
 | 
				
			||||||
 | 
					    timeout-minutes: 60
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        include:
 | 
				
			||||||
 | 
					          - target: x86_64-unknown-linux-gnu
 | 
				
			||||||
 | 
					            os: ubuntu-latest
 | 
				
			||||||
 | 
					            artifact_name: ailog
 | 
				
			||||||
 | 
					            asset_name: ailog-linux-x86_64
 | 
				
			||||||
 | 
					          - target: aarch64-unknown-linux-gnu
 | 
				
			||||||
 | 
					            os: ubuntu-latest
 | 
				
			||||||
 | 
					            artifact_name: ailog
 | 
				
			||||||
 | 
					            asset_name: ailog-linux-aarch64
 | 
				
			||||||
 | 
					          - target: x86_64-apple-darwin
 | 
				
			||||||
 | 
					            os: macos-latest
 | 
				
			||||||
 | 
					            artifact_name: ailog
 | 
				
			||||||
 | 
					            asset_name: ailog-macos-x86_64
 | 
				
			||||||
 | 
					          - target: aarch64-apple-darwin
 | 
				
			||||||
 | 
					            os: macos-latest
 | 
				
			||||||
 | 
					            artifact_name: ailog
 | 
				
			||||||
 | 
					            asset_name: ailog-macos-aarch64
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					    - uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Setup Rust
 | 
				
			||||||
 | 
					      uses: dtolnay/rust-toolchain@stable
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        targets: ${{ matrix.target }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Install cross-compilation tools (Linux)
 | 
				
			||||||
 | 
					      if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        sudo apt-get update
 | 
				
			||||||
 | 
					        sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Configure cross-compilation (Linux ARM64)
 | 
				
			||||||
 | 
					      if: matrix.target == 'aarch64-unknown-linux-gnu'
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
 | 
				
			||||||
 | 
					        echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Cache cargo registry
 | 
				
			||||||
 | 
					      uses: actions/cache@v4
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        path: |
 | 
				
			||||||
 | 
					          ~/.cargo/registry
 | 
				
			||||||
 | 
					          ~/.cargo/git
 | 
				
			||||||
 | 
					        key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Cache target directory
 | 
				
			||||||
 | 
					      uses: actions/cache@v4
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        path: target
 | 
				
			||||||
 | 
					        key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Build
 | 
				
			||||||
 | 
					      run: cargo build --release --target ${{ matrix.target }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Prepare binary
 | 
				
			||||||
 | 
					      shell: bash
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        cd target/${{ matrix.target }}/release
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Use appropriate strip command for cross-compilation
 | 
				
			||||||
 | 
					        if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
 | 
				
			||||||
 | 
					          aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
 | 
				
			||||||
 | 
					        elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
 | 
				
			||||||
 | 
					          strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					          strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
 | 
				
			||||||
 | 
					        fi
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Create archive
 | 
				
			||||||
 | 
					        if [[ "${{ matrix.target }}" == *"windows"* ]]; then
 | 
				
			||||||
 | 
					          7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					          tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
 | 
				
			||||||
 | 
					        fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Upload binary
 | 
				
			||||||
 | 
					      uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        name: ${{ matrix.asset_name }}
 | 
				
			||||||
 | 
					        path: ${{ matrix.asset_name }}.tar.gz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  release:
 | 
				
			||||||
 | 
					    name: Create Release
 | 
				
			||||||
 | 
					    needs: build
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    permissions:
 | 
				
			||||||
 | 
					      contents: write
 | 
				
			||||||
 | 
					      actions: read
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					    - uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Download all artifacts
 | 
				
			||||||
 | 
					      uses: actions/download-artifact@v4
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        path: artifacts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Generate release notes
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        echo "## What's Changed" > release_notes.md
 | 
				
			||||||
 | 
					        echo "" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "### Features" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- AI-powered static blog generator" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- AtProto OAuth integration" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- Automatic translation support" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- AI comment system" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "### Platforms" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- Linux (x86_64, aarch64)" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "### Installation" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "\`\`\`bash" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "# Linux/macOS" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "chmod +x ailog" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "" >> release_notes.md
 | 
				
			||||||
 | 
					        echo "\`\`\`" >> release_notes.md
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Get tag name
 | 
				
			||||||
 | 
					      id: tag_name
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
 | 
				
			||||||
 | 
					          echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					          echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
 | 
				
			||||||
 | 
					        fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Create Release with Gitea API
 | 
				
			||||||
 | 
					      run: |
 | 
				
			||||||
 | 
					        # Prepare release files
 | 
				
			||||||
 | 
					        mkdir -p release
 | 
				
			||||||
 | 
					        find artifacts -name "*.tar.gz" -exec cp {} release/ \;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Create release via Gitea API
 | 
				
			||||||
 | 
					        RELEASE_RESPONSE=$(curl -X POST \
 | 
				
			||||||
 | 
					          "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
 | 
				
			||||||
 | 
					          -H "Authorization: token ${{ github.token }}" \
 | 
				
			||||||
 | 
					          -H "Content-Type: application/json" \
 | 
				
			||||||
 | 
					          -d '{
 | 
				
			||||||
 | 
					            "tag_name": "${{ steps.tag_name.outputs.tag }}",
 | 
				
			||||||
 | 
					            "name": "ailog ${{ steps.tag_name.outputs.tag }}",
 | 
				
			||||||
 | 
					            "body": "'"$(cat release_notes.md | sed 's/"/\\"/g' | tr '\n' ' ')"'",
 | 
				
			||||||
 | 
					            "draft": false,
 | 
				
			||||||
 | 
					            "prerelease": '"$(if echo "${{ steps.tag_name.outputs.tag }}" | grep -E "(alpha|beta|rc)"; then echo "true"; else echo "false"; fi)"'
 | 
				
			||||||
 | 
					          }')
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Get release ID
 | 
				
			||||||
 | 
					        RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
 | 
				
			||||||
 | 
					        echo "Created release with ID: $RELEASE_ID"
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Upload release assets
 | 
				
			||||||
 | 
					        for file in release/*.tar.gz; do
 | 
				
			||||||
 | 
					          if [ -f "$file" ]; then
 | 
				
			||||||
 | 
					            filename=$(basename "$file")
 | 
				
			||||||
 | 
					            echo "Uploading $filename..."
 | 
				
			||||||
 | 
					            curl -X POST \
 | 
				
			||||||
 | 
					              "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" \
 | 
				
			||||||
 | 
					              -H "Authorization: token ${{ github.token }}" \
 | 
				
			||||||
 | 
					              -H "Content-Type: application/octet-stream" \
 | 
				
			||||||
 | 
					              --data-binary @"$file"
 | 
				
			||||||
 | 
					          fi
 | 
				
			||||||
 | 
					        done
 | 
				
			||||||
							
								
								
									
										93
									
								
								.github/workflows/cloudflare-pages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										93
									
								
								.github/workflows/cloudflare-pages.yml
									
									
									
									
										vendored
									
									
								
							@@ -7,7 +7,7 @@ on:
 | 
				
			|||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
env:
 | 
					env:
 | 
				
			||||||
  OAUTH_DIR: oauth_new
 | 
					  OAUTH_DIR: oauth
 | 
				
			||||||
  KEEP_DEPLOYMENTS: 5
 | 
					  KEEP_DEPLOYMENTS: 5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
@@ -110,48 +110,49 @@ jobs:
 | 
				
			|||||||
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}
 | 
					          gitHubToken: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          wranglerVersion: '3'
 | 
					          wranglerVersion: '3'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  cleanup:
 | 
					#  cleanup:
 | 
				
			||||||
    needs: deploy
 | 
					#    needs: deploy
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					#    runs-on: ubuntu-latest
 | 
				
			||||||
    if: success()
 | 
					#    if: success()
 | 
				
			||||||
    
 | 
					#    steps:
 | 
				
			||||||
    steps:
 | 
					#      - name: Cleanup old deployments
 | 
				
			||||||
      - name: Wait for deployment to complete
 | 
					#        run: |
 | 
				
			||||||
        run: sleep 3
 | 
					#          curl -X PATCH \
 | 
				
			||||||
        
 | 
					#            "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }} \
 | 
				
			||||||
      - name: Cleanup old deployments
 | 
					#            -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
				
			||||||
        run: |
 | 
					#            -H "Content-Type: application/json")
 | 
				
			||||||
          # Get all deployments
 | 
					#            -d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
 | 
				
			||||||
          DEPLOYMENTS=$(curl -s -X GET \
 | 
					#          # Get all deployments
 | 
				
			||||||
            "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
 | 
					#          DEPLOYMENTS=$(curl -s -X GET \
 | 
				
			||||||
            -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
					#            "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
 | 
				
			||||||
            -H "Content-Type: application/json")
 | 
					#            -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
				
			||||||
          
 | 
					#            -H "Content-Type: application/json")
 | 
				
			||||||
          # Extract deployment IDs (skip the latest N deployments)
 | 
					#          
 | 
				
			||||||
          DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
 | 
					#          # Extract deployment IDs (skip the latest N deployments)
 | 
				
			||||||
          
 | 
					#          DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
 | 
				
			||||||
          if [ -z "$DEPLOYMENT_IDS" ]; then
 | 
					#          
 | 
				
			||||||
            echo "No old deployments to delete"
 | 
					#          if [ -z "$DEPLOYMENT_IDS" ]; then
 | 
				
			||||||
            exit 0
 | 
					#            echo "No old deployments to delete"
 | 
				
			||||||
          fi
 | 
					#            exit 0
 | 
				
			||||||
          
 | 
					#          fi
 | 
				
			||||||
          # Delete old deployments
 | 
					#          
 | 
				
			||||||
          for ID in $DEPLOYMENT_IDS; do
 | 
					#          # Delete old deployments
 | 
				
			||||||
            echo "Deleting deployment: $ID"
 | 
					#          for ID in $DEPLOYMENT_IDS; do
 | 
				
			||||||
            RESPONSE=$(curl -s -X DELETE \
 | 
					#            echo "Deleting deployment: $ID"
 | 
				
			||||||
              "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
 | 
					#            RESPONSE=$(curl -s -X DELETE \
 | 
				
			||||||
              -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
					#              "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
 | 
				
			||||||
              -H "Content-Type: application/json")
 | 
					#              -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
 | 
				
			||||||
            
 | 
					#              -H "Content-Type: application/json")
 | 
				
			||||||
            SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
 | 
					#            
 | 
				
			||||||
            if [ "$SUCCESS" = "true" ]; then
 | 
					#            SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
 | 
				
			||||||
              echo "Successfully deleted deployment: $ID"
 | 
					#            if [ "$SUCCESS" = "true" ]; then
 | 
				
			||||||
            else
 | 
					#              echo "Successfully deleted deployment: $ID"
 | 
				
			||||||
              echo "Failed to delete deployment: $ID"
 | 
					#            else
 | 
				
			||||||
              echo "$RESPONSE" | jq .
 | 
					#              echo "Failed to delete deployment: $ID"
 | 
				
			||||||
            fi
 | 
					#              echo "$RESPONSE" | jq .
 | 
				
			||||||
            
 | 
					#            fi
 | 
				
			||||||
            sleep 1  # Rate limiting
 | 
					#            
 | 
				
			||||||
          done
 | 
					#            sleep 1  # Rate limiting
 | 
				
			||||||
          
 | 
					#          done
 | 
				
			||||||
          echo "Cleanup completed!"
 | 
					#          
 | 
				
			||||||
 | 
					#          echo "Cleanup completed!"
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -16,5 +16,6 @@ my-blog/static/index.html
 | 
				
			|||||||
my-blog/templates/oauth-assets.html
 | 
					my-blog/templates/oauth-assets.html
 | 
				
			||||||
cloudflared-config.yml
 | 
					cloudflared-config.yml
 | 
				
			||||||
.config
 | 
					.config
 | 
				
			||||||
oauth-server-example
 | 
					 | 
				
			||||||
atproto
 | 
					atproto
 | 
				
			||||||
 | 
					oauth_old
 | 
				
			||||||
 | 
					oauth_example
 | 
				
			||||||
 
 | 
				
			|||||||
										
											Binary file not shown.
										
									
								
							@@ -1,21 +1,10 @@
 | 
				
			|||||||
# Production environment variables
 | 
					VITE_ADMIN=ai.syui.ai
 | 
				
			||||||
VITE_APP_HOST=https://syui.ai
 | 
					VITE_PDS=syu.is
 | 
				
			||||||
 | 
					VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
 | 
				
			||||||
 | 
					VITE_COLLECTION=ai.syui.log
 | 
				
			||||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
 | 
					VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
 | 
				
			||||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
 | 
					VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Handle-based Configuration (DIDs resolved at runtime)
 | 
					# Production settings - Disable development features
 | 
				
			||||||
VITE_ATPROTO_PDS=syu.is
 | 
					VITE_ENABLE_TEST_UI=false
 | 
				
			||||||
VITE_ADMIN_HANDLE=ai.syui.ai
 | 
					VITE_ENABLE_DEBUG=false
 | 
				
			||||||
VITE_AI_HANDLE=ai.syui.ai
 | 
					 | 
				
			||||||
VITE_OAUTH_COLLECTION=ai.syui.log
 | 
					 | 
				
			||||||
VITE_ATPROTO_WEB_URL=https://bsky.app
 | 
					 | 
				
			||||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# AI Configuration
 | 
					 | 
				
			||||||
VITE_AI_ENABLED=true
 | 
					 | 
				
			||||||
VITE_AI_ASK_AI=true
 | 
					 | 
				
			||||||
VITE_AI_PROVIDER=ollama
 | 
					 | 
				
			||||||
VITE_AI_MODEL=gemma3:1b
 | 
					 | 
				
			||||||
VITE_AI_HOST=https://ollama.syui.ai
 | 
					 | 
				
			||||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@@ -1,20 +1,11 @@
 | 
				
			|||||||
<!DOCTYPE html>
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html lang="ja">
 | 
					<html>
 | 
				
			||||||
  <head>
 | 
					<head>
 | 
				
			||||||
    <meta charset="UTF-8" />
 | 
					  <meta charset="UTF-8">
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					  <title>Comments Test</title>
 | 
				
			||||||
    <title>ai.card</title>
 | 
					</head>
 | 
				
			||||||
    <style>
 | 
					<body>
 | 
				
			||||||
      body {
 | 
					  <div id="comment-atproto"></div>
 | 
				
			||||||
        margin: 0;
 | 
					  <script type="module" src="/src/main.jsx"></script>
 | 
				
			||||||
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 | 
					</body>
 | 
				
			||||||
        background-color: #0a0a0a;
 | 
					 | 
				
			||||||
        color: #ffffff;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    </style>
 | 
					 | 
				
			||||||
  </head>
 | 
					 | 
				
			||||||
  <body>
 | 
					 | 
				
			||||||
    <div id="root"></div>
 | 
					 | 
				
			||||||
    <script type="module" src="/src/main.tsx"></script>
 | 
					 | 
				
			||||||
  </body>
 | 
					 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
@@ -1,36 +1,22 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "aicard",
 | 
					  "name": "ailog-oauth",
 | 
				
			||||||
  "version": "0.1.1",
 | 
					  "version": "0.2.2",
 | 
				
			||||||
  "private": true,
 | 
					  "type": "module",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "dev": "vite --mode development",
 | 
					    "dev": "vite",
 | 
				
			||||||
    "build": "vite build --mode production",
 | 
					    "build": "vite build && node build-minimal.js",
 | 
				
			||||||
    "build:dev": "vite build --mode development",
 | 
					    "preview": "vite preview"
 | 
				
			||||||
    "build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
 | 
					 | 
				
			||||||
    "preview": "npm run test:console && vite preview",
 | 
					 | 
				
			||||||
    "test": "vitest",
 | 
					 | 
				
			||||||
    "test:console": "node -r esbuild-register src/tests/console-test.ts"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@atproto/api": "^0.15.12",
 | 
					 | 
				
			||||||
    "@atproto/did": "^0.1.5",
 | 
					 | 
				
			||||||
    "@atproto/identity": "^0.4.8",
 | 
					 | 
				
			||||||
    "@atproto/oauth-client-browser": "^0.3.19",
 | 
					 | 
				
			||||||
    "@atproto/xrpc": "^0.7.0",
 | 
					 | 
				
			||||||
    "axios": "^1.6.2",
 | 
					 | 
				
			||||||
    "framer-motion": "^10.16.16",
 | 
					 | 
				
			||||||
    "react": "^18.2.0",
 | 
					    "react": "^18.2.0",
 | 
				
			||||||
    "react-dom": "^18.2.0",
 | 
					    "react-dom": "^18.2.0",
 | 
				
			||||||
    "react-router-dom": "^7.6.1"
 | 
					    "@atproto/api": "^0.15.12",
 | 
				
			||||||
 | 
					    "@atproto/oauth-client-browser": "^0.3.19"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@types/react": "^18.2.45",
 | 
					    "@types/react": "^18.2.0",
 | 
				
			||||||
    "@types/react-dom": "^18.2.18",
 | 
					    "@types/react-dom": "^18.2.0",
 | 
				
			||||||
    "@vitejs/plugin-react": "^4.2.1",
 | 
					    "@vitejs/plugin-react": "^4.0.0",
 | 
				
			||||||
    "typescript": "^5.3.3",
 | 
					    "vite": "^5.0.0"
 | 
				
			||||||
    "vite": "^5.0.10",
 | 
					 | 
				
			||||||
    "vitest": "^1.1.0",
 | 
					 | 
				
			||||||
    "esbuild": "^0.19.10",
 | 
					 | 
				
			||||||
    "esbuild-register": "^3.5.0"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "keys": [
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "kty": "EC",
 | 
					 | 
				
			||||||
      "crv": "P-256",
 | 
					 | 
				
			||||||
      "x": "mock_x_coordinate_base64url",
 | 
					 | 
				
			||||||
      "y": "mock_y_coordinate_base64url",
 | 
					 | 
				
			||||||
      "d": "mock_private_key_base64url",
 | 
					 | 
				
			||||||
      "use": "sig",
 | 
					 | 
				
			||||||
      "kid": "ai-card-oauth-key-1",
 | 
					 | 
				
			||||||
      "alg": "ES256"
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  ]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,24 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "client_id": "https://syui.ai/client-metadata.json",
 | 
					 | 
				
			||||||
  "client_name": "ai.log",
 | 
					 | 
				
			||||||
  "client_uri": "https://syui.ai",
 | 
					 | 
				
			||||||
  "logo_uri": "https://syui.ai/favicon.ico",
 | 
					 | 
				
			||||||
  "tos_uri": "https://syui.ai/terms",
 | 
					 | 
				
			||||||
  "policy_uri": "https://syui.ai/privacy",
 | 
					 | 
				
			||||||
  "redirect_uris": [
 | 
					 | 
				
			||||||
    "https://syui.ai/oauth/callback",
 | 
					 | 
				
			||||||
    "https://syui.ai/"
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  "response_types": [
 | 
					 | 
				
			||||||
    "code"
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  "grant_types": [
 | 
					 | 
				
			||||||
    "authorization_code",
 | 
					 | 
				
			||||||
    "refresh_token"
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  "token_endpoint_auth_method": "none",
 | 
					 | 
				
			||||||
  "scope": "atproto transition:generic",
 | 
					 | 
				
			||||||
  "subject_type": "public",
 | 
					 | 
				
			||||||
  "application_type": "web",
 | 
					 | 
				
			||||||
  "dpop_bound_access_tokens": true
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										1512
									
								
								oauth/src/App.css
									
									
									
									
									
								
							
							
						
						
									
										1512
									
								
								oauth/src/App.css
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -133,6 +133,7 @@ export default function App() {
 | 
				
			|||||||
            <CommentForm
 | 
					            <CommentForm
 | 
				
			||||||
              user={user}
 | 
					              user={user}
 | 
				
			||||||
              agent={agent}
 | 
					              agent={agent}
 | 
				
			||||||
 | 
					              pageContext={pageContext}
 | 
				
			||||||
              onCommentPosted={() => {
 | 
					              onCommentPosted={() => {
 | 
				
			||||||
                refreshAdminData?.()
 | 
					                refreshAdminData?.()
 | 
				
			||||||
                refreshUserData?.()
 | 
					                refreshUserData?.()
 | 
				
			||||||
							
								
								
									
										1622
									
								
								oauth/src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										1622
									
								
								oauth/src/App.tsx
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,21 +0,0 @@
 | 
				
			|||||||
// Cloudflare Access対応版の例
 | 
					 | 
				
			||||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
 | 
					 | 
				
			||||||
  method: 'POST',
 | 
					 | 
				
			||||||
  headers: {
 | 
					 | 
				
			||||||
    'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
    // Cloudflare Access Service Token
 | 
					 | 
				
			||||||
    'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
 | 
					 | 
				
			||||||
    'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  body: JSON.stringify({
 | 
					 | 
				
			||||||
    model: aiConfig.model,
 | 
					 | 
				
			||||||
    prompt: prompt,
 | 
					 | 
				
			||||||
    stream: false,
 | 
					 | 
				
			||||||
    options: {
 | 
					 | 
				
			||||||
      temperature: 0.9,
 | 
					 | 
				
			||||||
      top_p: 0.9,
 | 
					 | 
				
			||||||
      num_predict: 200,
 | 
					 | 
				
			||||||
      repeat_penalty: 1.1,
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }),
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -1,271 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { User } from '../services/auth';
 | 
					 | 
				
			||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
 | 
					 | 
				
			||||||
import { appConfig, getCollectionNames } from '../config/app';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface AIChatProps {
 | 
					 | 
				
			||||||
  user: User | null;
 | 
					 | 
				
			||||||
  isEnabled: boolean;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
 | 
					 | 
				
			||||||
  const [chatHistory, setChatHistory] = useState<any[]>([]);
 | 
					 | 
				
			||||||
  const [isLoading, setIsLoading] = useState(false);
 | 
					 | 
				
			||||||
  const [isProcessing, setIsProcessing] = useState(false);
 | 
					 | 
				
			||||||
  const [aiProfile, setAiProfile] = useState<any>(null);
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Get AI settings from appConfig (unified configuration)
 | 
					 | 
				
			||||||
  const aiConfig = {
 | 
					 | 
				
			||||||
    enabled: appConfig.aiEnabled,
 | 
					 | 
				
			||||||
    askAi: appConfig.aiAskAi,
 | 
					 | 
				
			||||||
    provider: appConfig.aiProvider,
 | 
					 | 
				
			||||||
    model: appConfig.aiModel,
 | 
					 | 
				
			||||||
    host: appConfig.aiHost,
 | 
					 | 
				
			||||||
    systemPrompt: appConfig.aiSystemPrompt,
 | 
					 | 
				
			||||||
    aiDid: appConfig.aiDid,
 | 
					 | 
				
			||||||
    bskyPublicApi: appConfig.bskyPublicApi,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Fetch AI profile on load
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    const fetchAIProfile = async () => {
 | 
					 | 
				
			||||||
      if (!aiConfig.aiDid) {
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        // Try with agent first
 | 
					 | 
				
			||||||
        const agent = atprotoOAuthService.getAgent();
 | 
					 | 
				
			||||||
        if (agent) {
 | 
					 | 
				
			||||||
          const profile = await agent.getProfile({ actor: aiConfig.aiDid });
 | 
					 | 
				
			||||||
          const profileData = {
 | 
					 | 
				
			||||||
            did: aiConfig.aiDid,
 | 
					 | 
				
			||||||
            handle: profile.data.handle,
 | 
					 | 
				
			||||||
            displayName: profile.data.displayName,
 | 
					 | 
				
			||||||
            avatar: profile.data.avatar,
 | 
					 | 
				
			||||||
            description: profile.data.description
 | 
					 | 
				
			||||||
          };
 | 
					 | 
				
			||||||
          setAiProfile(profileData);
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Dispatch event to update Ask AI button
 | 
					 | 
				
			||||||
          window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
 | 
					 | 
				
			||||||
          return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Fallback to public API
 | 
					 | 
				
			||||||
        const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
 | 
					 | 
				
			||||||
        if (response.ok) {
 | 
					 | 
				
			||||||
          const profileData = await response.json();
 | 
					 | 
				
			||||||
          const profile = {
 | 
					 | 
				
			||||||
            did: aiConfig.aiDid,
 | 
					 | 
				
			||||||
            handle: profileData.handle,
 | 
					 | 
				
			||||||
            displayName: profileData.displayName,
 | 
					 | 
				
			||||||
            avatar: profileData.avatar,
 | 
					 | 
				
			||||||
            description: profileData.description
 | 
					 | 
				
			||||||
          };
 | 
					 | 
				
			||||||
          setAiProfile(profile);
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Dispatch event to update Ask AI button
 | 
					 | 
				
			||||||
          window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
 | 
					 | 
				
			||||||
          return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        setAiProfile(null);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    fetchAIProfile();
 | 
					 | 
				
			||||||
  }, [aiConfig.aiDid]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    if (!isEnabled || !aiConfig.askAi) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Listen for AI question posts from base.html
 | 
					 | 
				
			||||||
    const handleAIQuestion = async (event: any) => {
 | 
					 | 
				
			||||||
      if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      setIsProcessing(true);
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        await postQuestionAndGenerateResponse(event.detail.question);
 | 
					 | 
				
			||||||
      } finally {
 | 
					 | 
				
			||||||
        setIsProcessing(false);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Add listener with a small delay to ensure it's ready
 | 
					 | 
				
			||||||
    setTimeout(() => {
 | 
					 | 
				
			||||||
      window.addEventListener('postAIQuestion', handleAIQuestion);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Notify that AI is ready
 | 
					 | 
				
			||||||
      window.dispatchEvent(new CustomEvent('aiChatReady'));
 | 
					 | 
				
			||||||
    }, 100);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return () => {
 | 
					 | 
				
			||||||
      window.removeEventListener('postAIQuestion', handleAIQuestion);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }, [user, isEnabled, isProcessing, aiProfile]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const postQuestionAndGenerateResponse = async (question: string) => {
 | 
					 | 
				
			||||||
    if (!user || !aiConfig.askAi || !aiProfile) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    setIsLoading(true);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const agent = atprotoOAuthService.getAgent();
 | 
					 | 
				
			||||||
      if (!agent) throw new Error('No agent available');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Get collection names
 | 
					 | 
				
			||||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 1. Post question to ATProto
 | 
					 | 
				
			||||||
      const now = new Date();
 | 
					 | 
				
			||||||
      const rkey = now.toISOString().replace(/[:.]/g, '-');
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Extract post metadata from current page
 | 
					 | 
				
			||||||
      const currentUrl = window.location.href;
 | 
					 | 
				
			||||||
      const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
 | 
					 | 
				
			||||||
      const postTitle = document.title.replace(' - syui.ai', '') || '';
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const questionRecord = {
 | 
					 | 
				
			||||||
        $type: collections.chat,
 | 
					 | 
				
			||||||
        post: {
 | 
					 | 
				
			||||||
          url: currentUrl,
 | 
					 | 
				
			||||||
          slug: postSlug,
 | 
					 | 
				
			||||||
          title: postTitle,
 | 
					 | 
				
			||||||
          date: new Date().toISOString(),
 | 
					 | 
				
			||||||
          tags: [],
 | 
					 | 
				
			||||||
          language: "ja"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        type: "question",
 | 
					 | 
				
			||||||
        text: question,
 | 
					 | 
				
			||||||
        author: {
 | 
					 | 
				
			||||||
          did: user.did,
 | 
					 | 
				
			||||||
          handle: user.handle,
 | 
					 | 
				
			||||||
          avatar: user.avatar,
 | 
					 | 
				
			||||||
          displayName: user.displayName || user.handle,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        createdAt: now.toISOString(),
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await agent.api.com.atproto.repo.putRecord({
 | 
					 | 
				
			||||||
        repo: user.did,
 | 
					 | 
				
			||||||
        collection: collections.chat,
 | 
					 | 
				
			||||||
        rkey: rkey,
 | 
					 | 
				
			||||||
        record: questionRecord,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 2. Get chat history
 | 
					 | 
				
			||||||
      const chatRecords = await agent.api.com.atproto.repo.listRecords({
 | 
					 | 
				
			||||||
        repo: user.did,
 | 
					 | 
				
			||||||
        collection: collections.chat,
 | 
					 | 
				
			||||||
        limit: 10,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let chatHistoryText = '';
 | 
					 | 
				
			||||||
      if (chatRecords.data.records) {
 | 
					 | 
				
			||||||
        chatHistoryText = chatRecords.data.records
 | 
					 | 
				
			||||||
          .map((r: any) => {
 | 
					 | 
				
			||||||
            if (r.value.type === 'question') {
 | 
					 | 
				
			||||||
              return `User: ${r.value.text}`;
 | 
					 | 
				
			||||||
            } else if (r.value.type === 'answer') {
 | 
					 | 
				
			||||||
              return `AI: ${r.value.text}`;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return '';
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
          .filter(Boolean)
 | 
					 | 
				
			||||||
          .join('\n');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 3. Generate AI response based on provider
 | 
					 | 
				
			||||||
      let aiAnswer = '';
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // 3. Generate AI response using Ollama via proxy
 | 
					 | 
				
			||||||
      if (aiConfig.provider === 'ollama') {
 | 
					 | 
				
			||||||
        const prompt = `${aiConfig.systemPrompt}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Question: ${question}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Answer:`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const response = await fetch(`${aiConfig.host}/api/generate`, {
 | 
					 | 
				
			||||||
          method: 'POST',
 | 
					 | 
				
			||||||
          headers: {
 | 
					 | 
				
			||||||
            'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
            'Origin': 'https://syui.ai',
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          body: JSON.stringify({
 | 
					 | 
				
			||||||
            model: aiConfig.model,
 | 
					 | 
				
			||||||
            prompt: prompt,
 | 
					 | 
				
			||||||
            stream: false,
 | 
					 | 
				
			||||||
            options: {
 | 
					 | 
				
			||||||
              temperature: 0.9,
 | 
					 | 
				
			||||||
              top_p: 0.9,
 | 
					 | 
				
			||||||
              num_predict: 200, // Longer responses for better answers
 | 
					 | 
				
			||||||
              repeat_penalty: 1.1,
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }),
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!response.ok) {
 | 
					 | 
				
			||||||
          throw new Error('AI API request failed');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const data = await response.json();
 | 
					 | 
				
			||||||
        aiAnswer = data.response;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 4. Immediately dispatch event to update UI
 | 
					 | 
				
			||||||
      window.dispatchEvent(new CustomEvent('aiResponseReceived', {
 | 
					 | 
				
			||||||
        detail: { 
 | 
					 | 
				
			||||||
          answer: aiAnswer,
 | 
					 | 
				
			||||||
          aiProfile: aiProfile,
 | 
					 | 
				
			||||||
          timestamp: now.toISOString()
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 5. Save AI response in background
 | 
					 | 
				
			||||||
      const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const answerRecord = {
 | 
					 | 
				
			||||||
        $type: collections.chat,
 | 
					 | 
				
			||||||
        post: {
 | 
					 | 
				
			||||||
          url: currentUrl,
 | 
					 | 
				
			||||||
          slug: postSlug,
 | 
					 | 
				
			||||||
          title: postTitle,
 | 
					 | 
				
			||||||
          date: new Date().toISOString(),
 | 
					 | 
				
			||||||
          tags: [],
 | 
					 | 
				
			||||||
          language: "ja"
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        type: "answer",
 | 
					 | 
				
			||||||
        text: aiAnswer,
 | 
					 | 
				
			||||||
        author: {
 | 
					 | 
				
			||||||
          did: aiProfile.did,
 | 
					 | 
				
			||||||
          handle: aiProfile.handle,
 | 
					 | 
				
			||||||
          displayName: aiProfile.displayName,
 | 
					 | 
				
			||||||
          avatar: aiProfile.avatar,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        createdAt: now.toISOString(),
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Save to ATProto asynchronously (don't wait for it)
 | 
					 | 
				
			||||||
      agent.api.com.atproto.repo.putRecord({
 | 
					 | 
				
			||||||
        repo: user.did,
 | 
					 | 
				
			||||||
        collection: collections.chat,
 | 
					 | 
				
			||||||
        rkey: answerRkey,
 | 
					 | 
				
			||||||
        record: answerRecord,
 | 
					 | 
				
			||||||
      }).catch(err => {
 | 
					 | 
				
			||||||
        // Silent fail for AI response saving
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      window.dispatchEvent(new CustomEvent('aiResponseError', {
 | 
					 | 
				
			||||||
        detail: { error: 'AI応答の生成に失敗しました' }
 | 
					 | 
				
			||||||
      }));
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setIsLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // This component doesn't render anything - it just handles the logic
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,79 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { AtprotoAgent } from '@atproto/api';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface AIProfile {
 | 
					 | 
				
			||||||
  did: string;
 | 
					 | 
				
			||||||
  handle: string;
 | 
					 | 
				
			||||||
  displayName?: string;
 | 
					 | 
				
			||||||
  avatar?: string;
 | 
					 | 
				
			||||||
  description?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface AIProfileProps {
 | 
					 | 
				
			||||||
  aiDid: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
 | 
					 | 
				
			||||||
  const [profile, setProfile] = useState<AIProfile | null>(null);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    const fetchAIProfile = async () => {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        // Use public API to get profile information
 | 
					 | 
				
			||||||
        const agent = new AtprotoAgent({ service: 'https://bsky.social' });
 | 
					 | 
				
			||||||
        const response = await agent.getProfile({ actor: aiDid });
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        setProfile({
 | 
					 | 
				
			||||||
          did: response.data.did,
 | 
					 | 
				
			||||||
          handle: response.data.handle,
 | 
					 | 
				
			||||||
          displayName: response.data.displayName,
 | 
					 | 
				
			||||||
          avatar: response.data.avatar,
 | 
					 | 
				
			||||||
          description: response.data.description,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        // Failed to fetch AI profile
 | 
					 | 
				
			||||||
        // Fallback to basic info
 | 
					 | 
				
			||||||
        setProfile({
 | 
					 | 
				
			||||||
          did: aiDid,
 | 
					 | 
				
			||||||
          handle: 'ai-assistant',
 | 
					 | 
				
			||||||
          displayName: 'AI Assistant',
 | 
					 | 
				
			||||||
          description: 'AI assistant for this blog',
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      } finally {
 | 
					 | 
				
			||||||
        setLoading(false);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (aiDid) {
 | 
					 | 
				
			||||||
      fetchAIProfile();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, [aiDid]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return <div className="ai-profile-loading">Loading AI profile...</div>;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!profile) {
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="ai-profile">
 | 
					 | 
				
			||||||
      <div className="ai-avatar">
 | 
					 | 
				
			||||||
        {profile.avatar ? (
 | 
					 | 
				
			||||||
          <img src={profile.avatar} alt={profile.displayName || profile.handle} />
 | 
					 | 
				
			||||||
        ) : (
 | 
					 | 
				
			||||||
          <div className="ai-avatar-placeholder">🤖</div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
      <div className="ai-info">
 | 
					 | 
				
			||||||
        <div className="ai-name">{profile.displayName || profile.handle}</div>
 | 
					 | 
				
			||||||
        <div className="ai-handle">@{profile.handle}</div>
 | 
					 | 
				
			||||||
        {profile.description && (
 | 
					 | 
				
			||||||
          <div className="ai-description">{profile.description}</div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,120 +0,0 @@
 | 
				
			|||||||
import React from 'react';
 | 
					 | 
				
			||||||
import { motion } from 'framer-motion';
 | 
					 | 
				
			||||||
import { Card as CardType, CardRarity } from '../types/card';
 | 
					 | 
				
			||||||
import '../styles/Card.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface CardProps {
 | 
					 | 
				
			||||||
  card: CardType;
 | 
					 | 
				
			||||||
  isRevealing?: boolean;
 | 
					 | 
				
			||||||
  detailed?: boolean;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const CARD_INFO: Record<number, { name: string; color: string }> = {
 | 
					 | 
				
			||||||
  0: { name: "アイ", color: "#fff700" },
 | 
					 | 
				
			||||||
  1: { name: "夢幻", color: "#b19cd9" },
 | 
					 | 
				
			||||||
  2: { name: "光彩", color: "#ffd700" },
 | 
					 | 
				
			||||||
  3: { name: "中性子", color: "#cacfd2" },
 | 
					 | 
				
			||||||
  4: { name: "太陽", color: "#ff6b35" },
 | 
					 | 
				
			||||||
  5: { name: "夜空", color: "#1a1a2e" },
 | 
					 | 
				
			||||||
  6: { name: "雪", color: "#e3f2fd" },
 | 
					 | 
				
			||||||
  7: { name: "雷", color: "#ffd93d" },
 | 
					 | 
				
			||||||
  8: { name: "超究", color: "#6c5ce7" },
 | 
					 | 
				
			||||||
  9: { name: "剣", color: "#a8e6cf" },
 | 
					 | 
				
			||||||
  10: { name: "破壊", color: "#ff4757" },
 | 
					 | 
				
			||||||
  11: { name: "地球", color: "#4834d4" },
 | 
					 | 
				
			||||||
  12: { name: "天の川", color: "#9c88ff" },
 | 
					 | 
				
			||||||
  13: { name: "創造", color: "#00d2d3" },
 | 
					 | 
				
			||||||
  14: { name: "超新星", color: "#ff9ff3" },
 | 
					 | 
				
			||||||
  15: { name: "世界", color: "#54a0ff" },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
 | 
					 | 
				
			||||||
  const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
 | 
					 | 
				
			||||||
  const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const getRarityClass = () => {
 | 
					 | 
				
			||||||
    switch (card.status) {
 | 
					 | 
				
			||||||
      case CardRarity.UNIQUE:
 | 
					 | 
				
			||||||
        return 'card-unique';
 | 
					 | 
				
			||||||
      case CardRarity.KIRA:
 | 
					 | 
				
			||||||
        return 'card-kira';
 | 
					 | 
				
			||||||
      case CardRarity.SUPER_RARE:
 | 
					 | 
				
			||||||
        return 'card-super-rare';
 | 
					 | 
				
			||||||
      case CardRarity.RARE:
 | 
					 | 
				
			||||||
        return 'card-rare';
 | 
					 | 
				
			||||||
      default:
 | 
					 | 
				
			||||||
        return 'card-normal';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!detailed) {
 | 
					 | 
				
			||||||
    // Simple view - only image and frame
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <motion.div
 | 
					 | 
				
			||||||
        className={`card card-simple ${getRarityClass()}`}
 | 
					 | 
				
			||||||
        initial={isRevealing ? { rotateY: 180 } : {}}
 | 
					 | 
				
			||||||
        animate={isRevealing ? { rotateY: 0 } : {}}
 | 
					 | 
				
			||||||
        transition={{ duration: 0.8, type: "spring" }}
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <div className="card-frame">
 | 
					 | 
				
			||||||
          <img 
 | 
					 | 
				
			||||||
            src={imageUrl} 
 | 
					 | 
				
			||||||
            alt={cardInfo.name}
 | 
					 | 
				
			||||||
            className="card-image-simple"
 | 
					 | 
				
			||||||
            onError={(e) => {
 | 
					 | 
				
			||||||
              (e.target as HTMLImageElement).style.display = 'none';
 | 
					 | 
				
			||||||
            }}
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </motion.div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Detailed view - all information
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <motion.div
 | 
					 | 
				
			||||||
      className={`card ${getRarityClass()}`}
 | 
					 | 
				
			||||||
      initial={isRevealing ? { rotateY: 180 } : {}}
 | 
					 | 
				
			||||||
      animate={isRevealing ? { rotateY: 0 } : {}}
 | 
					 | 
				
			||||||
      transition={{ duration: 0.8, type: "spring" }}
 | 
					 | 
				
			||||||
      style={{
 | 
					 | 
				
			||||||
        '--card-color': cardInfo.color,
 | 
					 | 
				
			||||||
      } as React.CSSProperties}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <div className="card-inner">
 | 
					 | 
				
			||||||
        <div className="card-header">
 | 
					 | 
				
			||||||
          <span className="card-id">#{card.id}</span>
 | 
					 | 
				
			||||||
          <span className="card-cp">CP: {card.cp}</span>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        <div className="card-image-container">
 | 
					 | 
				
			||||||
          <img 
 | 
					 | 
				
			||||||
            src={imageUrl} 
 | 
					 | 
				
			||||||
            alt={cardInfo.name}
 | 
					 | 
				
			||||||
            className="card-image"
 | 
					 | 
				
			||||||
            onError={(e) => {
 | 
					 | 
				
			||||||
              (e.target as HTMLImageElement).style.display = 'none';
 | 
					 | 
				
			||||||
            }}
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        <div className="card-content">
 | 
					 | 
				
			||||||
          <h3 className="card-name">{cardInfo.name}</h3>
 | 
					 | 
				
			||||||
          {card.is_unique && (
 | 
					 | 
				
			||||||
            <div className="unique-badge">UNIQUE</div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        {card.skill && (
 | 
					 | 
				
			||||||
          <div className="card-skill">
 | 
					 | 
				
			||||||
            <p>{card.skill}</p>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        <div className="card-footer">
 | 
					 | 
				
			||||||
          <span className="card-rarity">{card.status.toUpperCase()}</span>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </motion.div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,171 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
 | 
					 | 
				
			||||||
import { Card } from './Card';
 | 
					 | 
				
			||||||
import '../styles/CardBox.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface CardBoxProps {
 | 
					 | 
				
			||||||
  userDid: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
 | 
					 | 
				
			||||||
  const [boxData, setBoxData] = useState<any>(null);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
  const [showJson, setShowJson] = useState(false);
 | 
					 | 
				
			||||||
  const [isDeleting, setIsDeleting] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    loadBoxData();
 | 
					 | 
				
			||||||
  }, [userDid]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const loadBoxData = async () => {
 | 
					 | 
				
			||||||
    setLoading(true);
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const data = await atprotoOAuthService.getCardsFromBox();
 | 
					 | 
				
			||||||
      setBoxData(data);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Failed to load card box
 | 
					 | 
				
			||||||
      setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSaveToBox = async () => {
 | 
					 | 
				
			||||||
    // 現在のカードデータを取得してボックスに保存
 | 
					 | 
				
			||||||
    // この部分は親コンポーネントから渡すか、APIから取得する必要があります
 | 
					 | 
				
			||||||
    alert('カードボックスへの保存機能は親コンポーネントから実行してください');
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleDeleteBox = async () => {
 | 
					 | 
				
			||||||
    if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    setIsDeleting(true);
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await atprotoOAuthService.deleteCardBox();
 | 
					 | 
				
			||||||
      setBoxData({ records: [] });
 | 
					 | 
				
			||||||
      alert('カードボックスを削除しました');
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Failed to delete card box
 | 
					 | 
				
			||||||
      setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setIsDeleting(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="card-box-container">
 | 
					 | 
				
			||||||
        <div className="loading">カードボックスを読み込み中...</div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (error) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="card-box-container">
 | 
					 | 
				
			||||||
        <div className="error">エラー: {error}</div>
 | 
					 | 
				
			||||||
        <button onClick={loadBoxData} className="retry-button">
 | 
					 | 
				
			||||||
          再試行
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const records = boxData?.records || [];
 | 
					 | 
				
			||||||
  const selfRecord = records.find((record: any) => record.uri.includes('/self'));
 | 
					 | 
				
			||||||
  const cards = selfRecord?.value?.cards || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="card-box-container">
 | 
					 | 
				
			||||||
      <div className="card-box-header">
 | 
					 | 
				
			||||||
        <h3>📦 atproto カードボックス</h3>
 | 
					 | 
				
			||||||
        <div className="box-actions">
 | 
					 | 
				
			||||||
          <button 
 | 
					 | 
				
			||||||
            onClick={() => setShowJson(!showJson)} 
 | 
					 | 
				
			||||||
            className="json-button"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            {showJson ? 'JSON非表示' : 'JSON表示'}
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
          <button onClick={loadBoxData} className="refresh-button">
 | 
					 | 
				
			||||||
            🔄 更新
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
          {cards.length > 0 && (
 | 
					 | 
				
			||||||
            <button 
 | 
					 | 
				
			||||||
              onClick={handleDeleteBox} 
 | 
					 | 
				
			||||||
              className="delete-button"
 | 
					 | 
				
			||||||
              disabled={isDeleting}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              {isDeleting ? '削除中...' : '🗑️ 削除'}
 | 
					 | 
				
			||||||
            </button>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="uri-display">
 | 
					 | 
				
			||||||
        <p>
 | 
					 | 
				
			||||||
          <strong>📍 URI:</strong> 
 | 
					 | 
				
			||||||
          <code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
 | 
					 | 
				
			||||||
        </p>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {showJson && (
 | 
					 | 
				
			||||||
        <div className="json-display">
 | 
					 | 
				
			||||||
          <h4>Raw JSON データ:</h4>
 | 
					 | 
				
			||||||
          <pre className="json-content">
 | 
					 | 
				
			||||||
            {JSON.stringify(boxData, null, 2)}
 | 
					 | 
				
			||||||
          </pre>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="box-stats">
 | 
					 | 
				
			||||||
        <p>
 | 
					 | 
				
			||||||
          <strong>総カード数:</strong> {cards.length}枚
 | 
					 | 
				
			||||||
          {selfRecord?.value?.updated_at && (
 | 
					 | 
				
			||||||
            <>
 | 
					 | 
				
			||||||
              <br />
 | 
					 | 
				
			||||||
              <strong>最終更新:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
 | 
					 | 
				
			||||||
            </>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </p>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {cards.length > 0 ? (
 | 
					 | 
				
			||||||
        <>
 | 
					 | 
				
			||||||
          <div className="card-grid">
 | 
					 | 
				
			||||||
            {cards.map((card: any, index: number) => (
 | 
					 | 
				
			||||||
              <div key={index} className="box-card-item">
 | 
					 | 
				
			||||||
                <Card 
 | 
					 | 
				
			||||||
                  card={{
 | 
					 | 
				
			||||||
                    id: card.id,
 | 
					 | 
				
			||||||
                    cp: card.cp,
 | 
					 | 
				
			||||||
                    status: card.status,
 | 
					 | 
				
			||||||
                    skill: card.skill,
 | 
					 | 
				
			||||||
                    owner_did: card.owner_did,
 | 
					 | 
				
			||||||
                    obtained_at: card.obtained_at,
 | 
					 | 
				
			||||||
                    is_unique: card.is_unique,
 | 
					 | 
				
			||||||
                    unique_id: card.unique_id
 | 
					 | 
				
			||||||
                  }} 
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <div className="card-info">
 | 
					 | 
				
			||||||
                  <small>ID: {card.id} | CP: {card.cp}</small>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            ))}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </>
 | 
					 | 
				
			||||||
      ) : (
 | 
					 | 
				
			||||||
        <div className="empty-box">
 | 
					 | 
				
			||||||
          <p>カードボックスにカードがありません</p>
 | 
					 | 
				
			||||||
          <p>カードを引いてからバックアップボタンを押してください</p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,113 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { Card } from './Card';
 | 
					 | 
				
			||||||
import { cardApi } from '../services/api';
 | 
					 | 
				
			||||||
import { Card as CardType } from '../types/card';
 | 
					 | 
				
			||||||
import '../styles/CardList.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface CardMasterData {
 | 
					 | 
				
			||||||
  id: number;
 | 
					 | 
				
			||||||
  name: string;
 | 
					 | 
				
			||||||
  ja_name: string;
 | 
					 | 
				
			||||||
  description: string;
 | 
					 | 
				
			||||||
  base_cp_min: number;
 | 
					 | 
				
			||||||
  base_cp_max: number;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const CardList: React.FC = () => {
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					 | 
				
			||||||
  const [masterData, setMasterData] = useState<CardMasterData[]>([]);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    loadMasterData();
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const loadMasterData = async () => {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      setLoading(true);
 | 
					 | 
				
			||||||
      const response = await fetch('http://localhost:8000/api/v1/cards/master');
 | 
					 | 
				
			||||||
      if (!response.ok) {
 | 
					 | 
				
			||||||
        throw new Error('Failed to fetch card master data');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const data = await response.json();
 | 
					 | 
				
			||||||
      setMasterData(data);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Failed to load card master data
 | 
					 | 
				
			||||||
      setError(err instanceof Error ? err.message : 'Failed to load card data');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="card-list-container">
 | 
					 | 
				
			||||||
        <div className="loading">Loading card data...</div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (error) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="card-list-container">
 | 
					 | 
				
			||||||
        <div className="error">Error: {error}</div>
 | 
					 | 
				
			||||||
        <button onClick={loadMasterData}>Retry</button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Create cards for all rarity patterns
 | 
					 | 
				
			||||||
  const rarityPatterns = ['normal', 'unique'] as const;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  masterData.forEach(data => {
 | 
					 | 
				
			||||||
    rarityPatterns.forEach(pattern => {
 | 
					 | 
				
			||||||
      const card: CardType = {
 | 
					 | 
				
			||||||
        id: data.id,
 | 
					 | 
				
			||||||
        cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
 | 
					 | 
				
			||||||
        status: pattern,
 | 
					 | 
				
			||||||
        skill: null,
 | 
					 | 
				
			||||||
        owner_did: 'sample',
 | 
					 | 
				
			||||||
        obtained_at: new Date().toISOString(),
 | 
					 | 
				
			||||||
        is_unique: pattern === 'unique',
 | 
					 | 
				
			||||||
        unique_id: pattern === 'unique' ? 'sample-unique-id' : null
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
      displayCards.push({
 | 
					 | 
				
			||||||
        card,
 | 
					 | 
				
			||||||
        data,
 | 
					 | 
				
			||||||
        patternName: `${data.id}-${pattern}`
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="card-list-container">
 | 
					 | 
				
			||||||
      <header className="card-list-header">
 | 
					 | 
				
			||||||
        <h1>ai.card マスターリスト</h1>
 | 
					 | 
				
			||||||
        <p>全カード・全レアリティパターン表示</p>
 | 
					 | 
				
			||||||
        <p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
 | 
					 | 
				
			||||||
      </header>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="card-list-simple-grid">
 | 
					 | 
				
			||||||
        {displayCards.map(({ card, data, patternName }) => (
 | 
					 | 
				
			||||||
          <div key={patternName} className="card-list-simple-item">
 | 
					 | 
				
			||||||
            <Card card={card} detailed={false} />
 | 
					 | 
				
			||||||
            <div className="card-info-details">
 | 
					 | 
				
			||||||
              <p><strong>ID:</strong> {data.id}</p>
 | 
					 | 
				
			||||||
              <p><strong>Name:</strong> {data.name}</p>
 | 
					 | 
				
			||||||
              <p><strong>日本語名:</strong> {data.ja_name}</p>
 | 
					 | 
				
			||||||
              <p><strong>レアリティ:</strong> {card.status}</p>
 | 
					 | 
				
			||||||
              <p><strong>CP:</strong> {card.cp}</p>
 | 
					 | 
				
			||||||
              <p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
 | 
					 | 
				
			||||||
              {data.description && (
 | 
					 | 
				
			||||||
                <p className="card-description">{data.description}</p>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        ))}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,133 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { aiCardApi } from '../services/api';
 | 
					 | 
				
			||||||
import '../styles/CollectionAnalysis.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface AnalysisData {
 | 
					 | 
				
			||||||
  total_cards: number;
 | 
					 | 
				
			||||||
  unique_cards: number;
 | 
					 | 
				
			||||||
  rarity_distribution: Record<string, number>;
 | 
					 | 
				
			||||||
  collection_score: number;
 | 
					 | 
				
			||||||
  recommendations: string[];
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface CollectionAnalysisProps {
 | 
					 | 
				
			||||||
  userDid: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
 | 
					 | 
				
			||||||
  const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(false);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const loadAnalysis = async () => {
 | 
					 | 
				
			||||||
    if (!userDid) return;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    setLoading(true);
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const result = await aiCardApi.analyzeCollection(userDid);
 | 
					 | 
				
			||||||
      setAnalysis(result);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Collection analysis failed
 | 
					 | 
				
			||||||
      setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    loadAnalysis();
 | 
					 | 
				
			||||||
  }, [userDid]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="collection-analysis">
 | 
					 | 
				
			||||||
        <div className="analysis-loading">
 | 
					 | 
				
			||||||
          <div className="loading-spinner"></div>
 | 
					 | 
				
			||||||
          <p>AI分析中...</p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (error) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="collection-analysis">
 | 
					 | 
				
			||||||
        <div className="analysis-error">
 | 
					 | 
				
			||||||
          <p>{error}</p>
 | 
					 | 
				
			||||||
          <button onClick={loadAnalysis} className="retry-button">
 | 
					 | 
				
			||||||
            再試行
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!analysis) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="collection-analysis">
 | 
					 | 
				
			||||||
        <div className="analysis-empty">
 | 
					 | 
				
			||||||
          <p>分析データがありません</p>
 | 
					 | 
				
			||||||
          <button onClick={loadAnalysis} className="analyze-button">
 | 
					 | 
				
			||||||
            分析開始
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="collection-analysis">
 | 
					 | 
				
			||||||
      <h3>🧠 AI コレクション分析</h3>
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      <div className="analysis-stats">
 | 
					 | 
				
			||||||
        <div className="stat-card">
 | 
					 | 
				
			||||||
          <div className="stat-value">{analysis.total_cards}</div>
 | 
					 | 
				
			||||||
          <div className="stat-label">総カード数</div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div className="stat-card">
 | 
					 | 
				
			||||||
          <div className="stat-value">{analysis.unique_cards}</div>
 | 
					 | 
				
			||||||
          <div className="stat-label">ユニークカード</div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div className="stat-card">
 | 
					 | 
				
			||||||
          <div className="stat-value">{analysis.collection_score}</div>
 | 
					 | 
				
			||||||
          <div className="stat-label">コレクションスコア</div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="rarity-distribution">
 | 
					 | 
				
			||||||
        <h4>レアリティ分布</h4>
 | 
					 | 
				
			||||||
        <div className="rarity-bars">
 | 
					 | 
				
			||||||
          {Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
 | 
					 | 
				
			||||||
            <div key={rarity} className="rarity-bar">
 | 
					 | 
				
			||||||
              <span className="rarity-name">{rarity}</span>
 | 
					 | 
				
			||||||
              <div className="bar-container">
 | 
					 | 
				
			||||||
                <div 
 | 
					 | 
				
			||||||
                  className={`bar bar-${rarity.toLowerCase()}`}
 | 
					 | 
				
			||||||
                  style={{ width: `${(count / analysis.total_cards) * 100}%` }}
 | 
					 | 
				
			||||||
                ></div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <span className="rarity-count">{count}</span>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          ))}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {analysis.recommendations && analysis.recommendations.length > 0 && (
 | 
					 | 
				
			||||||
        <div className="recommendations">
 | 
					 | 
				
			||||||
          <h4>🎯 AI推奨</h4>
 | 
					 | 
				
			||||||
          <ul>
 | 
					 | 
				
			||||||
            {analysis.recommendations.map((rec, index) => (
 | 
					 | 
				
			||||||
              <li key={index}>{rec}</li>
 | 
					 | 
				
			||||||
            ))}
 | 
					 | 
				
			||||||
          </ul>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <button onClick={loadAnalysis} className="refresh-analysis">
 | 
					 | 
				
			||||||
        分析更新
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -2,21 +2,34 @@ import React, { useState } from 'react'
 | 
				
			|||||||
import { atproto, collections } from '../api/atproto.js'
 | 
					import { atproto, collections } from '../api/atproto.js'
 | 
				
			||||||
import { env } from '../config/env.js'
 | 
					import { env } from '../config/env.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
					export default function CommentForm({ user, agent, onCommentPosted, pageContext }) {
 | 
				
			||||||
  const [text, setText] = useState('')
 | 
					  const [text, setText] = useState('')
 | 
				
			||||||
  const [url, setUrl] = useState('')
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(false)
 | 
					  const [loading, setLoading] = useState(false)
 | 
				
			||||||
  const [error, setError] = useState(null)
 | 
					  const [error, setError] = useState(null)
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
 | 
					  // Get current URL automatically, but exclude OAuth callback URLs
 | 
				
			||||||
 | 
					  const getCurrentUrl = () => {
 | 
				
			||||||
 | 
					    const currentPath = window.location.href
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // If on OAuth callback page, get the stored return URL or use root
 | 
				
			||||||
 | 
					    if (currentPath.includes('/oauth/callback')) {
 | 
				
			||||||
 | 
					      return sessionStorage.getItem('oauth_return_url') || window.location.origin
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Remove hash fragments for clean URLs
 | 
				
			||||||
 | 
					    return currentPath.split('#')[0]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const currentUrl = getCurrentUrl()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleSubmit = async (e) => {
 | 
					  const handleSubmit = async (e) => {
 | 
				
			||||||
    e.preventDefault()
 | 
					    e.preventDefault()
 | 
				
			||||||
    if (!text.trim() || !url.trim()) return
 | 
					    if (!text.trim()) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setLoading(true)
 | 
					    setLoading(true)
 | 
				
			||||||
    setError(null)
 | 
					    setError(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const currentUrl = url.trim()
 | 
					 | 
				
			||||||
      const timestamp = new Date().toISOString()
 | 
					      const timestamp = new Date().toISOString()
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
      // Create ai.syui.log record structure (new unified format)
 | 
					      // Create ai.syui.log record structure (new unified format)
 | 
				
			||||||
@@ -55,7 +68,6 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      // Clear form
 | 
					      // Clear form
 | 
				
			||||||
      setText('')
 | 
					      setText('')
 | 
				
			||||||
      setUrl('')
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Notify parent component
 | 
					      // Notify parent component
 | 
				
			||||||
      if (onCommentPosted) {
 | 
					      if (onCommentPosted) {
 | 
				
			||||||
@@ -86,18 +98,8 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
				
			|||||||
      <h3>コメントを投稿</h3>
 | 
					      <h3>コメントを投稿</h3>
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
      <form onSubmit={handleSubmit}>
 | 
					      <form onSubmit={handleSubmit}>
 | 
				
			||||||
        <div className="form-group">
 | 
					        <div className="form-group" style={{ marginBottom: '8px', fontSize: '0.9em', color: 'var(--text-secondary)' }}>
 | 
				
			||||||
          <label htmlFor="comment-url">ページURL:</label>
 | 
					          <span>ページ: {currentUrl}</span>
 | 
				
			||||||
          <input
 | 
					 | 
				
			||||||
            id="comment-url"
 | 
					 | 
				
			||||||
            type="url"
 | 
					 | 
				
			||||||
            value={url}
 | 
					 | 
				
			||||||
            onChange={(e) => setUrl(e.target.value)}
 | 
					 | 
				
			||||||
            placeholder="https://syui.ai/posts/example"
 | 
					 | 
				
			||||||
            required
 | 
					 | 
				
			||||||
            disabled={loading}
 | 
					 | 
				
			||||||
            className="form-input"
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className="form-group">
 | 
					        <div className="form-group">
 | 
				
			||||||
@@ -1,130 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { motion, AnimatePresence } from 'framer-motion';
 | 
					 | 
				
			||||||
import { Card } from './Card';
 | 
					 | 
				
			||||||
import { Card as CardType } from '../types/card';
 | 
					 | 
				
			||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
 | 
					 | 
				
			||||||
import '../styles/GachaAnimation.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface GachaAnimationProps {
 | 
					 | 
				
			||||||
  card: CardType;
 | 
					 | 
				
			||||||
  animationType: string;
 | 
					 | 
				
			||||||
  onComplete: () => void;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
 | 
					 | 
				
			||||||
  card,
 | 
					 | 
				
			||||||
  animationType,
 | 
					 | 
				
			||||||
  onComplete
 | 
					 | 
				
			||||||
}) => {
 | 
					 | 
				
			||||||
  const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
 | 
					 | 
				
			||||||
  const [showCard, setShowCard] = useState(false);
 | 
					 | 
				
			||||||
  const [isSharing, setIsSharing] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    const timer1 = setTimeout(() => setPhase('revealing'), 1500);
 | 
					 | 
				
			||||||
    const timer2 = setTimeout(() => {
 | 
					 | 
				
			||||||
      setPhase('complete');
 | 
					 | 
				
			||||||
      setShowCard(true);
 | 
					 | 
				
			||||||
    }, 3000);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return () => {
 | 
					 | 
				
			||||||
      clearTimeout(timer1);
 | 
					 | 
				
			||||||
      clearTimeout(timer2);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }, [onComplete]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleCardClick = () => {
 | 
					 | 
				
			||||||
    if (showCard) {
 | 
					 | 
				
			||||||
      onComplete();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSaveToCollection = async (e: React.MouseEvent) => {
 | 
					 | 
				
			||||||
    e.stopPropagation();
 | 
					 | 
				
			||||||
    if (isSharing) return;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    setIsSharing(true);
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await atprotoOAuthService.saveCardToCollection(card);
 | 
					 | 
				
			||||||
      alert('カードデータをatprotoコレクションに保存しました!');
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // Failed to save card
 | 
					 | 
				
			||||||
      alert('保存に失敗しました。認証が必要かもしれません。');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setIsSharing(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const getEffectClass = () => {
 | 
					 | 
				
			||||||
    switch (animationType) {
 | 
					 | 
				
			||||||
      case 'unique':
 | 
					 | 
				
			||||||
        return 'effect-unique';
 | 
					 | 
				
			||||||
      case 'kira':
 | 
					 | 
				
			||||||
        return 'effect-kira';
 | 
					 | 
				
			||||||
      case 'rare':
 | 
					 | 
				
			||||||
        return 'effect-rare';
 | 
					 | 
				
			||||||
      default:
 | 
					 | 
				
			||||||
        return 'effect-normal';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
 | 
					 | 
				
			||||||
      <AnimatePresence mode="wait">
 | 
					 | 
				
			||||||
        {phase === 'opening' && (
 | 
					 | 
				
			||||||
          <motion.div
 | 
					 | 
				
			||||||
            key="opening"
 | 
					 | 
				
			||||||
            className="gacha-opening"
 | 
					 | 
				
			||||||
            initial={{ scale: 0, rotate: -180 }}
 | 
					 | 
				
			||||||
            animate={{ scale: 1, rotate: 0 }}
 | 
					 | 
				
			||||||
            exit={{ scale: 0, opacity: 0 }}
 | 
					 | 
				
			||||||
            transition={{ duration: 0.8, type: "spring" }}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <div className="gacha-pack">
 | 
					 | 
				
			||||||
              <div className="pack-glow" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </motion.div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {phase === 'revealing' && (
 | 
					 | 
				
			||||||
          <motion.div
 | 
					 | 
				
			||||||
            key="revealing"
 | 
					 | 
				
			||||||
            initial={{ scale: 0, rotateY: 180 }}
 | 
					 | 
				
			||||||
            animate={{ scale: 1, rotateY: 0 }}
 | 
					 | 
				
			||||||
            transition={{ duration: 0.8, type: "spring" }}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <Card card={card} isRevealing={true} />
 | 
					 | 
				
			||||||
          </motion.div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {phase === 'complete' && showCard && (
 | 
					 | 
				
			||||||
          <motion.div
 | 
					 | 
				
			||||||
            key="complete"
 | 
					 | 
				
			||||||
            initial={{ scale: 1, rotateY: 0 }}
 | 
					 | 
				
			||||||
            animate={{ scale: 1, rotateY: 0 }}
 | 
					 | 
				
			||||||
            className="card-final"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <Card card={card} isRevealing={false} />
 | 
					 | 
				
			||||||
            <div className="card-actions">
 | 
					 | 
				
			||||||
              <button 
 | 
					 | 
				
			||||||
                className="save-button"
 | 
					 | 
				
			||||||
                onClick={handleSaveToCollection}
 | 
					 | 
				
			||||||
                disabled={isSharing}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {isSharing ? '保存中...' : '💾 atprotoに保存'}
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
              <div className="click-hint">クリックして閉じる</div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </motion.div>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
      </AnimatePresence>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {animationType === 'unique' && (
 | 
					 | 
				
			||||||
        <div className="unique-effect">
 | 
					 | 
				
			||||||
          <div className="unique-particles" />
 | 
					 | 
				
			||||||
          <div className="unique-burst" />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,144 +0,0 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import { cardApi, aiCardApi } from '../services/api';
 | 
					 | 
				
			||||||
import '../styles/GachaStats.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface GachaStatsData {
 | 
					 | 
				
			||||||
  total_draws: number;
 | 
					 | 
				
			||||||
  cards_by_rarity: Record<string, number>;
 | 
					 | 
				
			||||||
  success_rates: Record<string, number>;
 | 
					 | 
				
			||||||
  recent_activity: Array<{
 | 
					 | 
				
			||||||
    timestamp: string;
 | 
					 | 
				
			||||||
    user_did: string;
 | 
					 | 
				
			||||||
    card_name: string;
 | 
					 | 
				
			||||||
    rarity: string;
 | 
					 | 
				
			||||||
  }>;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const GachaStats: React.FC = () => {
 | 
					 | 
				
			||||||
  const [stats, setStats] = useState<GachaStatsData | null>(null);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(false);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
  const [useAI, setUseAI] = useState(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const loadStats = async () => {
 | 
					 | 
				
			||||||
    setLoading(true);
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      let result;
 | 
					 | 
				
			||||||
      if (useAI) {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          result = await aiCardApi.getEnhancedStats();
 | 
					 | 
				
			||||||
        } catch (aiError) {
 | 
					 | 
				
			||||||
          // AI stats unavailable, using basic stats
 | 
					 | 
				
			||||||
          setUseAI(false);
 | 
					 | 
				
			||||||
          result = await cardApi.getGachaStats();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        result = await cardApi.getGachaStats();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      setStats(result);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Gacha stats failed
 | 
					 | 
				
			||||||
      setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    loadStats();
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="gacha-stats">
 | 
					 | 
				
			||||||
        <div className="stats-loading">
 | 
					 | 
				
			||||||
          <div className="loading-spinner"></div>
 | 
					 | 
				
			||||||
          <p>統計データ取得中...</p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (error) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="gacha-stats">
 | 
					 | 
				
			||||||
        <div className="stats-error">
 | 
					 | 
				
			||||||
          <p>{error}</p>
 | 
					 | 
				
			||||||
          <button onClick={loadStats} className="retry-button">
 | 
					 | 
				
			||||||
            再試行
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!stats) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="gacha-stats">
 | 
					 | 
				
			||||||
        <div className="stats-empty">
 | 
					 | 
				
			||||||
          <p>統計データがありません</p>
 | 
					 | 
				
			||||||
          <button onClick={loadStats} className="load-stats-button">
 | 
					 | 
				
			||||||
            統計取得
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="gacha-stats">
 | 
					 | 
				
			||||||
      <h3>📊 ガチャ統計</h3>
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      <div className="stats-overview">
 | 
					 | 
				
			||||||
        <div className="overview-card">
 | 
					 | 
				
			||||||
          <div className="overview-value">{stats.total_draws}</div>
 | 
					 | 
				
			||||||
          <div className="overview-label">総ガチャ実行数</div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="rarity-stats">
 | 
					 | 
				
			||||||
        <h4>レアリティ別出現数</h4>
 | 
					 | 
				
			||||||
        <div className="rarity-grid">
 | 
					 | 
				
			||||||
          {Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
 | 
					 | 
				
			||||||
            <div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
 | 
					 | 
				
			||||||
              <div className="rarity-count">{count}</div>
 | 
					 | 
				
			||||||
              <div className="rarity-name">{rarity}</div>
 | 
					 | 
				
			||||||
              {stats.success_rates[rarity] && (
 | 
					 | 
				
			||||||
                <div className="success-rate">
 | 
					 | 
				
			||||||
                  {(stats.success_rates[rarity] * 100).toFixed(1)}%
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          ))}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {stats.recent_activity && stats.recent_activity.length > 0 && (
 | 
					 | 
				
			||||||
        <div className="recent-activity">
 | 
					 | 
				
			||||||
          <h4>最近の活動</h4>
 | 
					 | 
				
			||||||
          <div className="activity-list">
 | 
					 | 
				
			||||||
            {stats.recent_activity.slice(0, 5).map((activity, index) => (
 | 
					 | 
				
			||||||
              <div key={index} className="activity-item">
 | 
					 | 
				
			||||||
                <div className="activity-time">
 | 
					 | 
				
			||||||
                  {new Date(activity.timestamp).toLocaleString()}
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div className="activity-details">
 | 
					 | 
				
			||||||
                  <span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
 | 
					 | 
				
			||||||
                    {activity.rarity}
 | 
					 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                  <span className="card-name">{activity.card_name}</span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            ))}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <button onClick={loadStats} className="refresh-stats">
 | 
					 | 
				
			||||||
        統計更新
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,203 +0,0 @@
 | 
				
			|||||||
import React, { useState } from 'react';
 | 
					 | 
				
			||||||
import { motion } from 'framer-motion';
 | 
					 | 
				
			||||||
import { authService } from '../services/auth';
 | 
					 | 
				
			||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
 | 
					 | 
				
			||||||
import '../styles/Login.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface LoginProps {
 | 
					 | 
				
			||||||
  onLogin: (did: string, handle: string) => void;
 | 
					 | 
				
			||||||
  onClose: () => void;
 | 
					 | 
				
			||||||
  defaultHandle?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
 | 
					 | 
				
			||||||
  const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
 | 
					 | 
				
			||||||
  const [identifier, setIdentifier] = useState(defaultHandle || '');
 | 
					 | 
				
			||||||
  const [password, setPassword] = useState('');
 | 
					 | 
				
			||||||
  const [isLoading, setIsLoading] = useState(false);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleOAuthLogin = async () => {
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
    setIsLoading(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Prompt for handle if not provided
 | 
					 | 
				
			||||||
      const handle = identifier.trim() || undefined;
 | 
					 | 
				
			||||||
      await atprotoOAuthService.initiateOAuthFlow(handle);
 | 
					 | 
				
			||||||
      // OAuth flow will redirect, so we don't need to handle the response here
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      setError('OAuth認証の開始に失敗しました。');
 | 
					 | 
				
			||||||
      setIsLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleLegacyLogin = async (e: React.FormEvent) => {
 | 
					 | 
				
			||||||
    e.preventDefault();
 | 
					 | 
				
			||||||
    setError(null);
 | 
					 | 
				
			||||||
    setIsLoading(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await authService.login(identifier, password);
 | 
					 | 
				
			||||||
      onLogin(response.did, response.handle);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      setError('ログインに失敗しました。認証情報を確認してください。');
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      setIsLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <motion.div
 | 
					 | 
				
			||||||
      className="login-overlay"
 | 
					 | 
				
			||||||
      initial={{ opacity: 0 }}
 | 
					 | 
				
			||||||
      animate={{ opacity: 1 }}
 | 
					 | 
				
			||||||
      exit={{ opacity: 0 }}
 | 
					 | 
				
			||||||
      onClick={onClose}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <motion.div
 | 
					 | 
				
			||||||
        className="login-modal"
 | 
					 | 
				
			||||||
        initial={{ scale: 0.9, opacity: 0 }}
 | 
					 | 
				
			||||||
        animate={{ scale: 1, opacity: 1 }}
 | 
					 | 
				
			||||||
        transition={{ type: "spring", duration: 0.5 }}
 | 
					 | 
				
			||||||
        onClick={(e) => e.stopPropagation()}
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <h2>atprotoログイン</h2>
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        <div className="login-mode-selector">
 | 
					 | 
				
			||||||
          <button
 | 
					 | 
				
			||||||
            type="button"
 | 
					 | 
				
			||||||
            className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
 | 
					 | 
				
			||||||
            onClick={() => setLoginMode('oauth')}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            OAuth 2.1 (推奨)
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
          <button
 | 
					 | 
				
			||||||
            type="button"
 | 
					 | 
				
			||||||
            className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
 | 
					 | 
				
			||||||
            onClick={() => setLoginMode('legacy')}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            アプリパスワード
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {loginMode === 'oauth' ? (
 | 
					 | 
				
			||||||
          <div className="oauth-login">
 | 
					 | 
				
			||||||
            <div className="oauth-info">
 | 
					 | 
				
			||||||
              <h3>🔐 OAuth 2.1 認証</h3>
 | 
					 | 
				
			||||||
              <p>
 | 
					 | 
				
			||||||
                より安全で標準準拠の認証方式です。
 | 
					 | 
				
			||||||
                ブラウザが一時的にatproto認証サーバーにリダイレクトされます。
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
              {(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
 | 
					 | 
				
			||||||
                <div className="dev-notice">
 | 
					 | 
				
			||||||
                  <small>🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)</small>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <div className="form-group">
 | 
					 | 
				
			||||||
              <label htmlFor="oauth-identifier">Bluesky Handle</label>
 | 
					 | 
				
			||||||
              <input
 | 
					 | 
				
			||||||
                id="oauth-identifier"
 | 
					 | 
				
			||||||
                type="text"
 | 
					 | 
				
			||||||
                value={identifier}
 | 
					 | 
				
			||||||
                onChange={(e) => setIdentifier(e.target.value)}
 | 
					 | 
				
			||||||
                placeholder="your.handle.bsky.social"
 | 
					 | 
				
			||||||
                required
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            {error && (
 | 
					 | 
				
			||||||
              <div className="error-message">{error}</div>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <div className="button-group">
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                className="oauth-login-button"
 | 
					 | 
				
			||||||
                onClick={handleOAuthLogin}
 | 
					 | 
				
			||||||
                disabled={isLoading || !identifier.trim()}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {isLoading ? '認証開始中...' : 'atprotoで認証'}
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                className="cancel-button"
 | 
					 | 
				
			||||||
                onClick={onClose}
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                キャンセル
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        ) : (
 | 
					 | 
				
			||||||
          <form onSubmit={handleLegacyLogin}>
 | 
					 | 
				
			||||||
            <div className="form-group">
 | 
					 | 
				
			||||||
              <label htmlFor="identifier">ハンドル または DID</label>
 | 
					 | 
				
			||||||
              <input
 | 
					 | 
				
			||||||
                id="identifier"
 | 
					 | 
				
			||||||
                type="text"
 | 
					 | 
				
			||||||
                value={identifier}
 | 
					 | 
				
			||||||
                onChange={(e) => setIdentifier(e.target.value)}
 | 
					 | 
				
			||||||
                placeholder="your.handle または did:plc:..."
 | 
					 | 
				
			||||||
                required
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <div className="form-group">
 | 
					 | 
				
			||||||
              <label htmlFor="password">アプリパスワード</label>
 | 
					 | 
				
			||||||
              <input
 | 
					 | 
				
			||||||
                id="password"
 | 
					 | 
				
			||||||
                type="password"
 | 
					 | 
				
			||||||
                value={password}
 | 
					 | 
				
			||||||
                onChange={(e) => setPassword(e.target.value)}
 | 
					 | 
				
			||||||
                placeholder="アプリパスワード"
 | 
					 | 
				
			||||||
                required
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
              <small>
 | 
					 | 
				
			||||||
                メインパスワードではなく、
 | 
					 | 
				
			||||||
                <a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
 | 
					 | 
				
			||||||
                  アプリパスワード
 | 
					 | 
				
			||||||
                </a>
 | 
					 | 
				
			||||||
                を使用してください
 | 
					 | 
				
			||||||
              </small>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            {error && (
 | 
					 | 
				
			||||||
              <div className="error-message">{error}</div>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <div className="button-group">
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="submit"
 | 
					 | 
				
			||||||
                className="login-button"
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {isLoading ? 'ログイン中...' : 'ログイン'}
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                className="cancel-button"
 | 
					 | 
				
			||||||
                onClick={onClose}
 | 
					 | 
				
			||||||
                disabled={isLoading}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                キャンセル
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </form>
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="login-info">
 | 
					 | 
				
			||||||
          <p>
 | 
					 | 
				
			||||||
            ai.logはatprotoアカウントを使用します。
 | 
					 | 
				
			||||||
            コメントはあなたのPDSに保存されます。
 | 
					 | 
				
			||||||
          </p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </motion.div>
 | 
					 | 
				
			||||||
    </motion.div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -20,11 +20,18 @@ export default function OAuthCallback({ onAuthSuccess }) {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (code) {
 | 
					      if (code) {
 | 
				
			||||||
        setStatus('認証成功!メインページに戻ります...')
 | 
					        setStatus('認証成功!元のページに戻ります...')
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        // 少し待ってからメインページにリダイレクト
 | 
					        // 認証前のページを復元するか、ルートページに戻る
 | 
				
			||||||
 | 
					        const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'
 | 
				
			||||||
 | 
					        sessionStorage.removeItem('oauth_return_url')
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Clean up URL fragments and normalize
 | 
				
			||||||
 | 
					        const cleanReturnUrl = returnUrl.split('#')[0]
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // 少し待ってから元のページにリダイレクト
 | 
				
			||||||
        setTimeout(() => {
 | 
					        setTimeout(() => {
 | 
				
			||||||
          window.location.href = '/'
 | 
					          window.location.replace(cleanReturnUrl)
 | 
				
			||||||
        }, 1500)
 | 
					        }, 1500)
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        setStatus('認証情報が見つかりません')
 | 
					        setStatus('認証情報が見つかりません')
 | 
				
			||||||
@@ -1,228 +0,0 @@
 | 
				
			|||||||
import React, { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface OAuthCallbackProps {
 | 
					 | 
				
			||||||
  onSuccess: (did: string, handle: string) => void;
 | 
					 | 
				
			||||||
  onError: (error: string) => void;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const [isProcessing, setIsProcessing] = useState(true);
 | 
					 | 
				
			||||||
  const [needsHandle, setNeedsHandle] = useState(false);
 | 
					 | 
				
			||||||
  const [handle, setHandle] = useState('');
 | 
					 | 
				
			||||||
  const [tempSession, setTempSession] = useState<any>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    // Add timeout to prevent infinite loading
 | 
					 | 
				
			||||||
    const timeoutId = setTimeout(() => {
 | 
					 | 
				
			||||||
      onError('OAuth認証がタイムアウトしました');
 | 
					 | 
				
			||||||
    }, 10000); // 10 second timeout
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const handleCallback = async () => {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        // Handle both query params (?) and hash params (#)
 | 
					 | 
				
			||||||
        const hashParams = new URLSearchParams(window.location.hash.substring(1));
 | 
					 | 
				
			||||||
        const queryParams = new URLSearchParams(window.location.search);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Try hash first (Bluesky uses this), then fallback to query
 | 
					 | 
				
			||||||
        const code = hashParams.get('code') || queryParams.get('code');
 | 
					 | 
				
			||||||
        const state = hashParams.get('state') || queryParams.get('state');
 | 
					 | 
				
			||||||
        const error = hashParams.get('error') || queryParams.get('error');
 | 
					 | 
				
			||||||
        const iss = hashParams.get('iss') || queryParams.get('iss');
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (error) {
 | 
					 | 
				
			||||||
          throw new Error(`OAuth error: ${error}`);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!code || !state) {
 | 
					 | 
				
			||||||
          throw new Error('Missing OAuth parameters');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Use the official BrowserOAuthClient to handle the callback
 | 
					 | 
				
			||||||
        const result = await atprotoOAuthService.handleOAuthCallback();
 | 
					 | 
				
			||||||
        if (result) {
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Success - notify parent component
 | 
					 | 
				
			||||||
          onSuccess(result.did, result.handle);
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          throw new Error('OAuth callback did not return a session');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        // Even if OAuth fails, try to continue with a fallback approach
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          // Create a minimal session to allow the user to proceed
 | 
					 | 
				
			||||||
          const fallbackSession = {
 | 
					 | 
				
			||||||
            did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
 | 
					 | 
				
			||||||
            handle: 'syui.ai'
 | 
					 | 
				
			||||||
          };
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Notify success with fallback session
 | 
					 | 
				
			||||||
          onSuccess(fallbackSession.did, fallbackSession.handle);
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
        } catch (fallbackError) {
 | 
					 | 
				
			||||||
          onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } finally {
 | 
					 | 
				
			||||||
        clearTimeout(timeoutId); // Clear timeout on completion
 | 
					 | 
				
			||||||
        setIsProcessing(false);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    handleCallback();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Cleanup function
 | 
					 | 
				
			||||||
    return () => {
 | 
					 | 
				
			||||||
      clearTimeout(timeoutId);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }, [onSuccess, onError]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSubmitHandle = async (e?: React.FormEvent) => {
 | 
					 | 
				
			||||||
    if (e) e.preventDefault();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const trimmedHandle = handle.trim();
 | 
					 | 
				
			||||||
    if (!trimmedHandle) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    setIsProcessing(true);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Resolve DID from handle
 | 
					 | 
				
			||||||
      const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Update session with resolved DID and handle
 | 
					 | 
				
			||||||
      const updatedSession = {
 | 
					 | 
				
			||||||
        ...tempSession,
 | 
					 | 
				
			||||||
        did: did,
 | 
					 | 
				
			||||||
        handle: trimmedHandle
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Save updated session
 | 
					 | 
				
			||||||
      atprotoOAuthService.saveSessionToStorage(updatedSession);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Success - notify parent component
 | 
					 | 
				
			||||||
      onSuccess(did, trimmedHandle);
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      setIsProcessing(false);
 | 
					 | 
				
			||||||
      onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (needsHandle) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="oauth-callback">
 | 
					 | 
				
			||||||
        <div className="oauth-processing">
 | 
					 | 
				
			||||||
          <h2>Blueskyハンドルを入力してください</h2>
 | 
					 | 
				
			||||||
          <p>OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。</p>
 | 
					 | 
				
			||||||
          <p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
 | 
					 | 
				
			||||||
            入力中: {handle || '(未入力)'} | 文字数: {handle.length}
 | 
					 | 
				
			||||||
          </p>
 | 
					 | 
				
			||||||
          <form onSubmit={handleSubmitHandle}>
 | 
					 | 
				
			||||||
            <input
 | 
					 | 
				
			||||||
              type="text"
 | 
					 | 
				
			||||||
              value={handle}
 | 
					 | 
				
			||||||
              onChange={(e) => {
 | 
					 | 
				
			||||||
                setHandle(e.target.value);
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
              placeholder="例: syui.ai または user.bsky.social"
 | 
					 | 
				
			||||||
              autoFocus
 | 
					 | 
				
			||||||
              style={{
 | 
					 | 
				
			||||||
                width: '100%',
 | 
					 | 
				
			||||||
                padding: '10px',
 | 
					 | 
				
			||||||
                marginTop: '20px',
 | 
					 | 
				
			||||||
                marginBottom: '20px',
 | 
					 | 
				
			||||||
                borderRadius: '8px',
 | 
					 | 
				
			||||||
                border: '1px solid #ccc',
 | 
					 | 
				
			||||||
                fontSize: '16px',
 | 
					 | 
				
			||||||
                backgroundColor: '#1a1a1a',
 | 
					 | 
				
			||||||
                color: 'white'
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <button
 | 
					 | 
				
			||||||
              type="submit"
 | 
					 | 
				
			||||||
              disabled={!handle.trim() || isProcessing}
 | 
					 | 
				
			||||||
              style={{
 | 
					 | 
				
			||||||
                padding: '12px 24px',
 | 
					 | 
				
			||||||
                backgroundColor: handle.trim() ? '#667eea' : '#444',
 | 
					 | 
				
			||||||
                color: 'white',
 | 
					 | 
				
			||||||
                border: 'none',
 | 
					 | 
				
			||||||
                borderRadius: '8px',
 | 
					 | 
				
			||||||
                cursor: handle.trim() ? 'pointer' : 'not-allowed',
 | 
					 | 
				
			||||||
                fontSize: '16px',
 | 
					 | 
				
			||||||
                fontWeight: 'bold',
 | 
					 | 
				
			||||||
                transition: 'all 0.3s ease',
 | 
					 | 
				
			||||||
                width: '100%'
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              {isProcessing ? '処理中...' : '続行'}
 | 
					 | 
				
			||||||
            </button>
 | 
					 | 
				
			||||||
          </form>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (isProcessing) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="oauth-callback">
 | 
					 | 
				
			||||||
        <div className="oauth-processing">
 | 
					 | 
				
			||||||
          <div className="loading-spinner"></div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// CSS styles (inline for simplicity)
 | 
					 | 
				
			||||||
const styles = `
 | 
					 | 
				
			||||||
.oauth-callback {
 | 
					 | 
				
			||||||
  position: fixed;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  width: 100vw;
 | 
					 | 
				
			||||||
  height: 100vh;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
 | 
					 | 
				
			||||||
  color: #333;
 | 
					 | 
				
			||||||
  z-index: 9999;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-processing {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.8);
 | 
					 | 
				
			||||||
  border-radius: 16px;
 | 
					 | 
				
			||||||
  backdrop-filter: blur(10px);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.loading-spinner {
 | 
					 | 
				
			||||||
  width: 40px;
 | 
					 | 
				
			||||||
  height: 40px;
 | 
					 | 
				
			||||||
  border: 3px solid rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
  border-top: 3px solid #1185fe;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  animation: spin 1s linear infinite;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes spin {
 | 
					 | 
				
			||||||
  0% { transform: rotate(0deg); }
 | 
					 | 
				
			||||||
  100% { transform: rotate(360deg); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Inject styles
 | 
					 | 
				
			||||||
const styleSheet = document.createElement('style');
 | 
					 | 
				
			||||||
styleSheet.type = 'text/css';
 | 
					 | 
				
			||||||
styleSheet.innerText = styles;
 | 
					 | 
				
			||||||
document.head.appendChild(styleSheet);
 | 
					 | 
				
			||||||
@@ -1,36 +0,0 @@
 | 
				
			|||||||
import React, { useEffect } from 'react';
 | 
					 | 
				
			||||||
import { useNavigate } from 'react-router-dom';
 | 
					 | 
				
			||||||
import { OAuthCallback } from './OAuthCallback';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const OAuthCallbackPage: React.FC = () => {
 | 
					 | 
				
			||||||
  const navigate = useNavigate();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSuccess = (did: string, handle: string) => {
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Add a small delay to ensure state is properly updated
 | 
					 | 
				
			||||||
    setTimeout(() => {
 | 
					 | 
				
			||||||
      navigate('/', { replace: true });
 | 
					 | 
				
			||||||
    }, 100);
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleError = (error: string) => {
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Add a small delay before redirect
 | 
					 | 
				
			||||||
    setTimeout(() => {
 | 
					 | 
				
			||||||
      navigate('/', { replace: true });
 | 
					 | 
				
			||||||
    }, 2000); // Give user time to see error
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div>
 | 
					 | 
				
			||||||
      <h2>Processing OAuth callback...</h2>
 | 
					 | 
				
			||||||
      <OAuthCallback
 | 
					 | 
				
			||||||
        onSuccess={handleSuccess}
 | 
					 | 
				
			||||||
        onError={handleError}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,158 +0,0 @@
 | 
				
			|||||||
// Application configuration
 | 
					 | 
				
			||||||
export interface AppConfig {
 | 
					 | 
				
			||||||
  adminDid: string;
 | 
					 | 
				
			||||||
  adminHandle: string;
 | 
					 | 
				
			||||||
  aiDid: string;
 | 
					 | 
				
			||||||
  aiHandle: string;
 | 
					 | 
				
			||||||
  aiDisplayName: string;
 | 
					 | 
				
			||||||
  aiAvatar: string;
 | 
					 | 
				
			||||||
  aiDescription: string;
 | 
					 | 
				
			||||||
  collections: {
 | 
					 | 
				
			||||||
    base: string;  // Base collection like "ai.syui.log"
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  host: string;
 | 
					 | 
				
			||||||
  rkey?: string; // Current post rkey if on post page
 | 
					 | 
				
			||||||
  aiEnabled: boolean;
 | 
					 | 
				
			||||||
  aiAskAi: boolean;
 | 
					 | 
				
			||||||
  aiProvider: string;
 | 
					 | 
				
			||||||
  aiModel: string;
 | 
					 | 
				
			||||||
  aiHost: string;
 | 
					 | 
				
			||||||
  aiSystemPrompt: string;
 | 
					 | 
				
			||||||
  allowedHandles: string[]; // Handles allowed for OAuth authentication
 | 
					 | 
				
			||||||
  atprotoPds: string; // Configured PDS for admin/ai handles
 | 
					 | 
				
			||||||
  // Legacy - prefer per-user PDS detection
 | 
					 | 
				
			||||||
  bskyPublicApi: string;
 | 
					 | 
				
			||||||
  atprotoApi: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Collection name builders (similar to Rust implementation)
 | 
					 | 
				
			||||||
export function getCollectionNames(base: string) {
 | 
					 | 
				
			||||||
  if (!base) {
 | 
					 | 
				
			||||||
    // Fallback to default
 | 
					 | 
				
			||||||
    base = 'ai.syui.log';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const collections = {
 | 
					 | 
				
			||||||
    comment: base,
 | 
					 | 
				
			||||||
    user: `${base}.user`,
 | 
					 | 
				
			||||||
    chat: `${base}.chat`,
 | 
					 | 
				
			||||||
    chatLang: `${base}.chat.lang`,
 | 
					 | 
				
			||||||
    chatComment: `${base}.chat.comment`,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  return collections;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Generate collection names from host
 | 
					 | 
				
			||||||
// Format: ${reg}.${name}.${sub}
 | 
					 | 
				
			||||||
// Example: log.syui.ai -> ai.syui.log
 | 
					 | 
				
			||||||
function generateBaseCollectionFromHost(host: string): string {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // Remove protocol if present
 | 
					 | 
				
			||||||
    const cleanHost = host.replace(/^https?:\/\//, '');
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Split host into parts
 | 
					 | 
				
			||||||
    const parts = cleanHost.split('.');
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (parts.length < 2) {
 | 
					 | 
				
			||||||
      throw new Error('Invalid host format');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Reverse the parts for collection naming
 | 
					 | 
				
			||||||
    // log.syui.ai -> ai.syui.log
 | 
					 | 
				
			||||||
    const reversedParts = parts.reverse();
 | 
					 | 
				
			||||||
    const result = reversedParts.join('.');
 | 
					 | 
				
			||||||
    return result;
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    // Fallback to default
 | 
					 | 
				
			||||||
    return 'ai.syui.log';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Extract rkey from current URL
 | 
					 | 
				
			||||||
// /posts/xxx -> xxx (remove .html if present)
 | 
					 | 
				
			||||||
function extractRkeyFromUrl(): string | undefined {
 | 
					 | 
				
			||||||
  const pathname = window.location.pathname;
 | 
					 | 
				
			||||||
  const match = pathname.match(/\/posts\/([^/]+)\/?$/);
 | 
					 | 
				
			||||||
  if (match) {
 | 
					 | 
				
			||||||
    // Remove .html extension if present
 | 
					 | 
				
			||||||
    return match[1].replace(/\.html$/, '');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return undefined;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Get application configuration from environment variables
 | 
					 | 
				
			||||||
export function getAppConfig(): AppConfig {
 | 
					 | 
				
			||||||
  const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
 | 
					 | 
				
			||||||
  const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai';
 | 
					 | 
				
			||||||
  const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai';
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
 | 
					 | 
				
			||||||
  const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
 | 
					 | 
				
			||||||
  const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
 | 
					 | 
				
			||||||
  const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
 | 
					 | 
				
			||||||
  const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
 | 
					 | 
				
			||||||
  const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Priority: Environment variables > Auto-generated from host
 | 
					 | 
				
			||||||
  const autoGeneratedBase = generateBaseCollectionFromHost(host);
 | 
					 | 
				
			||||||
  let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Ensure base collection is never undefined
 | 
					 | 
				
			||||||
  if (!baseCollection) {
 | 
					 | 
				
			||||||
    baseCollection = 'ai.syui.log';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const collections = {
 | 
					 | 
				
			||||||
    base: baseCollection,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  const rkey = extractRkeyFromUrl();
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // AI configuration
 | 
					 | 
				
			||||||
  const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
 | 
					 | 
				
			||||||
  const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
 | 
					 | 
				
			||||||
  const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
 | 
					 | 
				
			||||||
  const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
 | 
					 | 
				
			||||||
  const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
 | 
					 | 
				
			||||||
  const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
 | 
					 | 
				
			||||||
  const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
 | 
					 | 
				
			||||||
  const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
 | 
					 | 
				
			||||||
  const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social';
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Parse allowed handles list
 | 
					 | 
				
			||||||
  const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
 | 
					 | 
				
			||||||
  let allowedHandles: string[] = [];
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    allowedHandles = JSON.parse(allowedHandlesStr);
 | 
					 | 
				
			||||||
  } catch {
 | 
					 | 
				
			||||||
    // If parsing fails, allow all handles (empty array means no restriction)
 | 
					 | 
				
			||||||
    allowedHandles = [];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    adminDid,
 | 
					 | 
				
			||||||
    adminHandle,
 | 
					 | 
				
			||||||
    aiDid,
 | 
					 | 
				
			||||||
    aiHandle,
 | 
					 | 
				
			||||||
    aiDisplayName,
 | 
					 | 
				
			||||||
    aiAvatar,
 | 
					 | 
				
			||||||
    aiDescription,
 | 
					 | 
				
			||||||
    collections,
 | 
					 | 
				
			||||||
    host,
 | 
					 | 
				
			||||||
    rkey,
 | 
					 | 
				
			||||||
    aiEnabled,
 | 
					 | 
				
			||||||
    aiAskAi,
 | 
					 | 
				
			||||||
    aiProvider,
 | 
					 | 
				
			||||||
    aiModel,
 | 
					 | 
				
			||||||
    aiHost,
 | 
					 | 
				
			||||||
    aiSystemPrompt,
 | 
					 | 
				
			||||||
    allowedHandles,
 | 
					 | 
				
			||||||
    atprotoPds,
 | 
					 | 
				
			||||||
    bskyPublicApi,
 | 
					 | 
				
			||||||
    atprotoApi
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Export singleton instance
 | 
					 | 
				
			||||||
export const appConfig = getAppConfig();
 | 
					 | 
				
			||||||
@@ -1,28 +0,0 @@
 | 
				
			|||||||
import React from 'react'
 | 
					 | 
				
			||||||
import ReactDOM from 'react-dom/client'
 | 
					 | 
				
			||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
 | 
					 | 
				
			||||||
import App from './App'
 | 
					 | 
				
			||||||
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
 | 
					 | 
				
			||||||
import { CardList } from './components/CardList'
 | 
					 | 
				
			||||||
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
 | 
					 | 
				
			||||||
// DISABLED: This may interfere with BrowserOAuthClient
 | 
					 | 
				
			||||||
// OAuthEndpointHandler.init()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Mount React app to all comment-atproto divs
 | 
					 | 
				
			||||||
const mountPoints = document.querySelectorAll('#comment-atproto');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
mountPoints.forEach((mountPoint, index) => {
 | 
					 | 
				
			||||||
  ReactDOM.createRoot(mountPoint as HTMLElement).render(
 | 
					 | 
				
			||||||
    <React.StrictMode>
 | 
					 | 
				
			||||||
      <BrowserRouter>
 | 
					 | 
				
			||||||
        <Routes>
 | 
					 | 
				
			||||||
          <Route path="/oauth/callback" element={<OAuthCallbackPage />} />
 | 
					 | 
				
			||||||
          <Route path="/list" element={<CardList />} />
 | 
					 | 
				
			||||||
          <Route path="*" element={<App />} />
 | 
					 | 
				
			||||||
        </Routes>
 | 
					 | 
				
			||||||
      </BrowserRouter>
 | 
					 | 
				
			||||||
    </React.StrictMode>,
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@@ -1,105 +0,0 @@
 | 
				
			|||||||
import axios from 'axios';
 | 
					 | 
				
			||||||
import { CardDrawResult } from '../types/card';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// ai.card 直接APIアクセス(メイン)
 | 
					 | 
				
			||||||
const API_HOST = import.meta.env.VITE_API_HOST || '';
 | 
					 | 
				
			||||||
const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// ai.gpt MCP統合(オプション機能)
 | 
					 | 
				
			||||||
const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true' 
 | 
					 | 
				
			||||||
  ? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
 | 
					 | 
				
			||||||
  : null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const cardApi_internal = axios.create({
 | 
					 | 
				
			||||||
  baseURL: API_BASE,
 | 
					 | 
				
			||||||
  headers: {
 | 
					 | 
				
			||||||
    'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const aiGptApi = AI_GPT_BASE ? axios.create({
 | 
					 | 
				
			||||||
  baseURL: AI_GPT_BASE,
 | 
					 | 
				
			||||||
  headers: {
 | 
					 | 
				
			||||||
    'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
}) : null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// ai.cardの直接API(基本機能)
 | 
					 | 
				
			||||||
export const cardApi = {
 | 
					 | 
				
			||||||
  drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.post('/cards/draw', {
 | 
					 | 
				
			||||||
      user_did: userDid,
 | 
					 | 
				
			||||||
      is_paid: isPaid,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getUserCards: async (userDid: string) => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.get(`/cards/user/${userDid}`);
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getCardDetails: async (cardId: number) => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.get(`/cards/${cardId}`);
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getUniqueCards: async () => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.get('/cards/unique');
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getGachaStats: async () => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.get('/cards/stats');
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // システム状態確認
 | 
					 | 
				
			||||||
  getSystemStatus: async () => {
 | 
					 | 
				
			||||||
    const response = await cardApi_internal.get('/health');
 | 
					 | 
				
			||||||
    return response.data;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// ai.gpt統合API(オプション機能 - AI拡張)
 | 
					 | 
				
			||||||
export const aiCardApi = {
 | 
					 | 
				
			||||||
  analyzeCollection: async (userDid: string) => {
 | 
					 | 
				
			||||||
    if (!aiGptApi) {
 | 
					 | 
				
			||||||
      throw new Error('AI機能が無効化されています');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await aiGptApi.get('/card_analyze_collection', {
 | 
					 | 
				
			||||||
        params: { did: userDid }
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      return response.data.data;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getEnhancedStats: async () => {
 | 
					 | 
				
			||||||
    if (!aiGptApi) {
 | 
					 | 
				
			||||||
      throw new Error('AI機能が無効化されています');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await aiGptApi.get('/card_get_gacha_stats');
 | 
					 | 
				
			||||||
      return response.data.data;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // AI機能が利用可能かチェック
 | 
					 | 
				
			||||||
  isAIAvailable: async (): Promise<boolean> => {
 | 
					 | 
				
			||||||
    if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await aiGptApi.get('/health');
 | 
					 | 
				
			||||||
      return true;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,571 +0,0 @@
 | 
				
			|||||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
 | 
					 | 
				
			||||||
import { Agent } from '@atproto/api';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface AtprotoSession {
 | 
					 | 
				
			||||||
  did: string;
 | 
					 | 
				
			||||||
  handle: string;
 | 
					 | 
				
			||||||
  accessJwt: string;
 | 
					 | 
				
			||||||
  refreshJwt: string;
 | 
					 | 
				
			||||||
  email?: string;
 | 
					 | 
				
			||||||
  emailConfirmed?: boolean;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AtprotoOAuthService {
 | 
					 | 
				
			||||||
  private oauthClient: BrowserOAuthClient | null = null;
 | 
					 | 
				
			||||||
  private oauthClientSyuIs: BrowserOAuthClient | null = null;
 | 
					 | 
				
			||||||
  private agent: Agent | null = null;
 | 
					 | 
				
			||||||
  private initializePromise: Promise<void> | null = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor() {
 | 
					 | 
				
			||||||
    // Don't initialize immediately, wait for first use
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async initialize(): Promise<void> {
 | 
					 | 
				
			||||||
    // Prevent multiple initializations
 | 
					 | 
				
			||||||
    if (this.initializePromise) {
 | 
					 | 
				
			||||||
      return this.initializePromise;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.initializePromise = this._doInitialize();
 | 
					 | 
				
			||||||
    return this.initializePromise;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async _doInitialize(): Promise<void> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Generate client ID based on current origin
 | 
					 | 
				
			||||||
      const clientId = this.getClientId();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Initialize both OAuth clients
 | 
					 | 
				
			||||||
      this.oauthClient = await BrowserOAuthClient.load({
 | 
					 | 
				
			||||||
        clientId: clientId,
 | 
					 | 
				
			||||||
        handleResolver: 'https://bsky.social',
 | 
					 | 
				
			||||||
        plcDirectoryUrl: 'https://plc.directory',
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      this.oauthClientSyuIs = await BrowserOAuthClient.load({
 | 
					 | 
				
			||||||
        clientId: clientId,
 | 
					 | 
				
			||||||
        handleResolver: 'https://syu.is',
 | 
					 | 
				
			||||||
        plcDirectoryUrl: 'https://plc.syu.is',
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Try to restore existing session from either client
 | 
					 | 
				
			||||||
      let result = await this.oauthClient.init();
 | 
					 | 
				
			||||||
      if (!result?.session) {
 | 
					 | 
				
			||||||
        result = await this.oauthClientSyuIs.init();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      if (result?.session) {
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Create Agent instance with proper configuration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Delete the old agent initialization code - we'll create it properly below
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Set the session after creating the agent
 | 
					 | 
				
			||||||
        // The session object from BrowserOAuthClient appears to be a special object
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Try to iterate over the session object
 | 
					 | 
				
			||||||
        if (result.session) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          for (const key in result.session) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Check if session has methods
 | 
					 | 
				
			||||||
          const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // BrowserOAuthClient might return a Session object that needs to be used with the agent
 | 
					 | 
				
			||||||
        // Let's try to use the session object directly with the agent
 | 
					 | 
				
			||||||
        if (result.session) {
 | 
					 | 
				
			||||||
          // Process the session to extract DID and handle
 | 
					 | 
				
			||||||
          const sessionData = await this.processSession(result.session);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      this.initializePromise = null; // Reset on error to allow retry
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async processSession(session: any): Promise<{ did: string; handle: string }> {
 | 
					 | 
				
			||||||
    const did = session.sub || session.did;
 | 
					 | 
				
			||||||
    let handle = session.handle || 'unknown';
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Create Agent directly with session (per official docs)
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      this.agent = new Agent(session);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      // Fallback to dpopFetch method
 | 
					 | 
				
			||||||
      this.agent = new Agent({
 | 
					 | 
				
			||||||
        service: session.server?.serviceEndpoint || 'https://bsky.social',
 | 
					 | 
				
			||||||
        fetch: session.dpopFetch
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Store basic session info
 | 
					 | 
				
			||||||
    (this as any)._sessionInfo = { did, handle };
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // If handle is missing, try multiple methods to resolve it
 | 
					 | 
				
			||||||
    if (!handle || handle === 'unknown') {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Method 1: Try using the agent to get profile
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        await new Promise(resolve => setTimeout(resolve, 300));
 | 
					 | 
				
			||||||
        const profile = await this.agent.getProfile({ actor: did });
 | 
					 | 
				
			||||||
        if (profile.data.handle) {
 | 
					 | 
				
			||||||
          handle = profile.data.handle;
 | 
					 | 
				
			||||||
          (this as any)._sessionInfo.handle = handle;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return { did, handle };
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (err) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Method 2: Try using describeRepo
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const repoDesc = await this.agent.com.atproto.repo.describeRepo({
 | 
					 | 
				
			||||||
          repo: did
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        if (repoDesc.data.handle) {
 | 
					 | 
				
			||||||
          handle = repoDesc.data.handle;
 | 
					 | 
				
			||||||
          (this as any)._sessionInfo.handle = handle;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return { did, handle };
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (err) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Method 3: Fallback for admin DID
 | 
					 | 
				
			||||||
      const adminDid = import.meta.env.VITE_ADMIN_DID;
 | 
					 | 
				
			||||||
      if (did === adminDid) {
 | 
					 | 
				
			||||||
        const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
 | 
					 | 
				
			||||||
        handle = new URL(appHost).hostname;
 | 
					 | 
				
			||||||
        (this as any)._sessionInfo.handle = handle;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return { did, handle };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private getClientId(): string {
 | 
					 | 
				
			||||||
    // Use environment variable if available
 | 
					 | 
				
			||||||
    const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
 | 
					 | 
				
			||||||
    if (envClientId) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return envClientId;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const origin = window.location.origin;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // For localhost development, use undefined for loopback client
 | 
					 | 
				
			||||||
    // The BrowserOAuthClient will handle this automatically
 | 
					 | 
				
			||||||
    if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return undefined as any; // Loopback client
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Default: use origin-based client metadata
 | 
					 | 
				
			||||||
    return `${origin}/client-metadata.json`;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async initiateOAuthFlow(handle?: string): Promise<void> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      if (!this.oauthClient || !this.oauthClientSyuIs) {
 | 
					 | 
				
			||||||
        await this.initialize();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!this.oauthClient || !this.oauthClientSyuIs) {
 | 
					 | 
				
			||||||
        throw new Error('Failed to initialize OAuth clients');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If handle is not provided, prompt user
 | 
					 | 
				
			||||||
      if (!handle) {
 | 
					 | 
				
			||||||
        handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):');
 | 
					 | 
				
			||||||
        if (!handle) {
 | 
					 | 
				
			||||||
          throw new Error('Handle is required for authentication');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Determine which OAuth client to use
 | 
					 | 
				
			||||||
      const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
 | 
					 | 
				
			||||||
      let allowedHandles: string[] = [];
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        allowedHandles = JSON.parse(allowedHandlesStr);
 | 
					 | 
				
			||||||
      } catch {
 | 
					 | 
				
			||||||
        allowedHandles = [];
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
 | 
					 | 
				
			||||||
      const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Start OAuth authorization flow
 | 
					 | 
				
			||||||
      const authUrl = await oauthClient.authorize(handle, {
 | 
					 | 
				
			||||||
        scope: 'atproto transition:generic',
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Redirect to authorization server
 | 
					 | 
				
			||||||
      window.location.href = authUrl.toString();
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // BrowserOAuthClient should automatically handle the callback
 | 
					 | 
				
			||||||
      // We just need to initialize it and it will process the current URL
 | 
					 | 
				
			||||||
      if (!this.oauthClient) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await this.initialize();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!this.oauthClient) {
 | 
					 | 
				
			||||||
        throw new Error('Failed to initialize OAuth client');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Call init() again to process the callback URL
 | 
					 | 
				
			||||||
      const result = await this.oauthClient.init();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      if (result?.session) {
 | 
					 | 
				
			||||||
        // Process the session
 | 
					 | 
				
			||||||
        return this.processSession(result.session);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // If no session yet, wait a bit and try again
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await new Promise(resolve => setTimeout(resolve, 1000));
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Try to check session again
 | 
					 | 
				
			||||||
      const sessionCheck = await this.checkSession();
 | 
					 | 
				
			||||||
      if (sessionCheck) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return sessionCheck;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return null;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async checkSession(): Promise<{ did: string; handle: string } | null> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      if (!this.oauthClient) {
 | 
					 | 
				
			||||||
        await this.initialize();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!this.oauthClient) {
 | 
					 | 
				
			||||||
        return null;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const result = await this.oauthClient.init();
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      if (result?.session) {
 | 
					 | 
				
			||||||
        // Use the common session processing method
 | 
					 | 
				
			||||||
        return this.processSession(result.session);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      return null;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return null;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getAgent(): Agent | null {
 | 
					 | 
				
			||||||
    return this.agent;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getSession(): AtprotoSession | null {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // First check if we have an agent with session
 | 
					 | 
				
			||||||
    if (this.agent?.session) {
 | 
					 | 
				
			||||||
      const session = {
 | 
					 | 
				
			||||||
        did: this.agent.session.did,
 | 
					 | 
				
			||||||
        handle: this.agent.session.handle || 'unknown',
 | 
					 | 
				
			||||||
        accessJwt: this.agent.session.accessJwt || '',
 | 
					 | 
				
			||||||
        refreshJwt: this.agent.session.refreshJwt || '',
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return session;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // If no agent.session but we have stored session info, return that
 | 
					 | 
				
			||||||
    if ((this as any)._sessionInfo) {
 | 
					 | 
				
			||||||
      const session = {
 | 
					 | 
				
			||||||
        did: (this as any)._sessionInfo.did,
 | 
					 | 
				
			||||||
        handle: (this as any)._sessionInfo.handle,
 | 
					 | 
				
			||||||
        accessJwt: 'dpop-protected',  // Indicate that tokens are handled by dpopFetch
 | 
					 | 
				
			||||||
        refreshJwt: 'dpop-protected',
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return session;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  isAuthenticated(): boolean {
 | 
					 | 
				
			||||||
    return !!this.agent || !!(this as any)._sessionInfo;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getUser(): { did: string; handle: string } | null {
 | 
					 | 
				
			||||||
    const session = this.getSession();
 | 
					 | 
				
			||||||
    if (!session) return null;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      did: session.did,
 | 
					 | 
				
			||||||
      handle: session.handle
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async logout(): Promise<void> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Clear Agent
 | 
					 | 
				
			||||||
      this.agent = null;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Clear BrowserOAuthClient session
 | 
					 | 
				
			||||||
      if (this.oauthClient) {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          // BrowserOAuthClient may have a revoke or signOut method
 | 
					 | 
				
			||||||
          if (typeof (this.oauthClient as any).signOut === 'function') {
 | 
					 | 
				
			||||||
            await (this.oauthClient as any).signOut();
 | 
					 | 
				
			||||||
          } else if (typeof (this.oauthClient as any).revoke === 'function') {
 | 
					 | 
				
			||||||
            await (this.oauthClient as any).revoke();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } catch (oauthError) {
 | 
					 | 
				
			||||||
          // Ignore logout errors
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Reset the OAuth client to force re-initialization
 | 
					 | 
				
			||||||
        this.oauthClient = null;
 | 
					 | 
				
			||||||
        this.initializePromise = null;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Clear any stored session data
 | 
					 | 
				
			||||||
      localStorage.removeItem('atproto_session');
 | 
					 | 
				
			||||||
      sessionStorage.clear();
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Clear all OAuth-related storage
 | 
					 | 
				
			||||||
      for (let i = 0; i < localStorage.length; i++) {
 | 
					 | 
				
			||||||
        const key = localStorage.key(i);
 | 
					 | 
				
			||||||
        if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
 | 
					 | 
				
			||||||
          localStorage.removeItem(key);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Clear internal session info
 | 
					 | 
				
			||||||
      (this as any)._sessionInfo = null;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Force page reload to ensure clean state
 | 
					 | 
				
			||||||
      setTimeout(() => {
 | 
					 | 
				
			||||||
        window.location.reload();
 | 
					 | 
				
			||||||
      }, 100);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // カードデータをatproto collectionに保存
 | 
					 | 
				
			||||||
  async saveCardToBox(userCards: any[]): Promise<void> {
 | 
					 | 
				
			||||||
    // Ensure we have a valid session
 | 
					 | 
				
			||||||
    const sessionInfo = await this.checkSession();
 | 
					 | 
				
			||||||
    if (!sessionInfo) {
 | 
					 | 
				
			||||||
      throw new Error('認証が必要です。ログインしてください。');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const did = sessionInfo.did;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Ensure we have a fresh agent
 | 
					 | 
				
			||||||
      if (!this.agent) {
 | 
					 | 
				
			||||||
        throw new Error('Agentが初期化されていません。');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const collection = 'ai.card.box';
 | 
					 | 
				
			||||||
      const rkey = 'self';
 | 
					 | 
				
			||||||
      const createdAt = new Date().toISOString();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // カードボックスのレコード
 | 
					 | 
				
			||||||
      const record = {
 | 
					 | 
				
			||||||
        $type: 'ai.card.box',
 | 
					 | 
				
			||||||
        cards: userCards.map(card => ({
 | 
					 | 
				
			||||||
          id: card.id,
 | 
					 | 
				
			||||||
          cp: card.cp,
 | 
					 | 
				
			||||||
          status: card.status,
 | 
					 | 
				
			||||||
          skill: card.skill,
 | 
					 | 
				
			||||||
          owner_did: card.owner_did,
 | 
					 | 
				
			||||||
          obtained_at: card.obtained_at,
 | 
					 | 
				
			||||||
          is_unique: card.is_unique,
 | 
					 | 
				
			||||||
          unique_id: card.unique_id
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        })),
 | 
					 | 
				
			||||||
        total_cards: userCards.length,
 | 
					 | 
				
			||||||
        updated_at: createdAt,
 | 
					 | 
				
			||||||
        createdAt: createdAt
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Use Agent's com.atproto.repo.putRecord method
 | 
					 | 
				
			||||||
      const response = await this.agent.com.atproto.repo.putRecord({
 | 
					 | 
				
			||||||
        repo: did,
 | 
					 | 
				
			||||||
        collection: collection,
 | 
					 | 
				
			||||||
        rkey: rkey,
 | 
					 | 
				
			||||||
        record: record
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // ai.card.boxからカード一覧を取得
 | 
					 | 
				
			||||||
  async getCardsFromBox(): Promise<any> {
 | 
					 | 
				
			||||||
    // Ensure we have a valid session
 | 
					 | 
				
			||||||
    const sessionInfo = await this.checkSession();
 | 
					 | 
				
			||||||
    if (!sessionInfo) {
 | 
					 | 
				
			||||||
      throw new Error('認証が必要です。ログインしてください。');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const did = sessionInfo.did;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Ensure we have a fresh agent
 | 
					 | 
				
			||||||
      if (!this.agent) {
 | 
					 | 
				
			||||||
        throw new Error('Agentが初期化されていません。');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const response = await this.agent.com.atproto.repo.getRecord({
 | 
					 | 
				
			||||||
        repo: did,
 | 
					 | 
				
			||||||
        collection: 'ai.card.box',
 | 
					 | 
				
			||||||
        rkey: 'self'
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Convert to expected format
 | 
					 | 
				
			||||||
      const result = {
 | 
					 | 
				
			||||||
        records: [{
 | 
					 | 
				
			||||||
          uri: `at://${did}/ai.card.box/self`,
 | 
					 | 
				
			||||||
          cid: response.data.cid,
 | 
					 | 
				
			||||||
          value: response.data.value
 | 
					 | 
				
			||||||
        }]
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      return result;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // If record doesn't exist, return empty
 | 
					 | 
				
			||||||
      if (error.toString().includes('RecordNotFound')) {
 | 
					 | 
				
			||||||
        return { records: [] };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // ai.card.boxのコレクションを削除
 | 
					 | 
				
			||||||
  async deleteCardBox(): Promise<void> {
 | 
					 | 
				
			||||||
    // Ensure we have a valid session
 | 
					 | 
				
			||||||
    const sessionInfo = await this.checkSession();
 | 
					 | 
				
			||||||
    if (!sessionInfo) {
 | 
					 | 
				
			||||||
      throw new Error('認証が必要です。ログインしてください。');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const did = sessionInfo.did;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Ensure we have a fresh agent
 | 
					 | 
				
			||||||
      if (!this.agent) {
 | 
					 | 
				
			||||||
        throw new Error('Agentが初期化されていません。');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      const response = await this.agent.com.atproto.repo.deleteRecord({
 | 
					 | 
				
			||||||
        repo: did,
 | 
					 | 
				
			||||||
        collection: 'ai.card.box',
 | 
					 | 
				
			||||||
        rkey: 'self'
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // 手動でトークンを設定(開発・デバッグ用)
 | 
					 | 
				
			||||||
  setManualTokens(accessJwt: string, refreshJwt: string): void {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // For backward compatibility, store in localStorage
 | 
					 | 
				
			||||||
    const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
 | 
					 | 
				
			||||||
    const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
 | 
					 | 
				
			||||||
    const session: AtprotoSession = {
 | 
					 | 
				
			||||||
      did: adminDid,
 | 
					 | 
				
			||||||
      handle: new URL(appHost).hostname,
 | 
					 | 
				
			||||||
      accessJwt: accessJwt,
 | 
					 | 
				
			||||||
      refreshJwt: refreshJwt
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    localStorage.setItem('atproto_session', JSON.stringify(session));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // 後方互換性のための従来関数
 | 
					 | 
				
			||||||
  saveSessionToStorage(session: AtprotoSession): void {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    localStorage.setItem('atproto_session', JSON.stringify(session));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async backupUserCards(userCards: any[]): Promise<void> {
 | 
					 | 
				
			||||||
    return this.saveCardToBox(userCards);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const atprotoOAuthService = new AtprotoOAuthService();
 | 
					 | 
				
			||||||
export type { AtprotoSession };
 | 
					 | 
				
			||||||
@@ -1,109 +0,0 @@
 | 
				
			|||||||
import axios from 'axios';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const API_BASE = '/api/v1';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface LoginRequest {
 | 
					 | 
				
			||||||
  identifier: string;  // Handle or DID
 | 
					 | 
				
			||||||
  password: string;    // App password
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface LoginResponse {
 | 
					 | 
				
			||||||
  access_token: string;
 | 
					 | 
				
			||||||
  token_type: string;
 | 
					 | 
				
			||||||
  did: string;
 | 
					 | 
				
			||||||
  handle: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface User {
 | 
					 | 
				
			||||||
  did: string;
 | 
					 | 
				
			||||||
  handle: string;
 | 
					 | 
				
			||||||
  avatar?: string;
 | 
					 | 
				
			||||||
  displayName?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AuthService {
 | 
					 | 
				
			||||||
  private token: string | null = null;
 | 
					 | 
				
			||||||
  private user: User | null = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor() {
 | 
					 | 
				
			||||||
    // Load token from localStorage
 | 
					 | 
				
			||||||
    this.token = localStorage.getItem('ai_card_token');
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Set default auth header if token exists
 | 
					 | 
				
			||||||
    if (this.token) {
 | 
					 | 
				
			||||||
      axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async login(identifier: string, password: string): Promise<LoginResponse> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await axios.post<LoginResponse>(`${API_BASE}/auth/login`, {
 | 
					 | 
				
			||||||
        identifier,
 | 
					 | 
				
			||||||
        password
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const { access_token, did, handle } = response.data;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Store token
 | 
					 | 
				
			||||||
      this.token = access_token;
 | 
					 | 
				
			||||||
      localStorage.setItem('ai_card_token', access_token);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Set auth header
 | 
					 | 
				
			||||||
      axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Store user info
 | 
					 | 
				
			||||||
      this.user = { did, handle };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return response.data;
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      throw new Error('Login failed');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async logout(): Promise<void> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await axios.post(`${API_BASE}/auth/logout`);
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // Ignore errors
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Clear token
 | 
					 | 
				
			||||||
    this.token = null;
 | 
					 | 
				
			||||||
    this.user = null;
 | 
					 | 
				
			||||||
    localStorage.removeItem('ai_card_token');
 | 
					 | 
				
			||||||
    delete axios.defaults.headers.common['Authorization'];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async verify(): Promise<User | null> {
 | 
					 | 
				
			||||||
    if (!this.token) {
 | 
					 | 
				
			||||||
      return null;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await axios.get<User & { valid: boolean }>(`${API_BASE}/auth/verify`);
 | 
					 | 
				
			||||||
      if (response.data.valid) {
 | 
					 | 
				
			||||||
        this.user = {
 | 
					 | 
				
			||||||
          did: response.data.did,
 | 
					 | 
				
			||||||
          handle: response.data.handle
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
        return this.user;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // Token is invalid
 | 
					 | 
				
			||||||
      this.logout();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getUser(): User | null {
 | 
					 | 
				
			||||||
    return this.user;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  isAuthenticated(): boolean {
 | 
					 | 
				
			||||||
    return this.token !== null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const authService = new AuthService();
 | 
					 | 
				
			||||||
export type { User, LoginRequest, LoginResponse };
 | 
					 | 
				
			||||||
@@ -97,6 +97,13 @@ export class OAuthService {
 | 
				
			|||||||
  async login(handle) {
 | 
					  async login(handle) {
 | 
				
			||||||
    await this.initialize()
 | 
					    await this.initialize()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Save current URL for return after OAuth
 | 
				
			||||||
 | 
					    const currentUrl = window.location.href
 | 
				
			||||||
 | 
					    // Only save if not already on oauth callback page
 | 
				
			||||||
 | 
					    if (!currentUrl.includes('/oauth/callback')) {
 | 
				
			||||||
 | 
					      sessionStorage.setItem('oauth_return_url', currentUrl)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
 | 
					    const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
 | 
				
			||||||
    const authUrl = await client.authorize(handle, { 
 | 
					    const authUrl = await client.authorize(handle, { 
 | 
				
			||||||
      scope: 'atproto transition:generic' 
 | 
					      scope: 'atproto transition:generic' 
 | 
				
			||||||
@@ -1,331 +0,0 @@
 | 
				
			|||||||
.card {
 | 
					 | 
				
			||||||
  width: 250px;
 | 
					 | 
				
			||||||
  height: 380px;
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
 | 
					 | 
				
			||||||
  border: 2px solid #333;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: transform 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-5px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-inner {
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  z-index: 1;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Rarity effects */
 | 
					 | 
				
			||||||
.card-normal {
 | 
					 | 
				
			||||||
  border-color: #666;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-rare {
 | 
					 | 
				
			||||||
  border-color: #4a90e2;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-super-rare {
 | 
					 | 
				
			||||||
  border-color: #9c27b0;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-kira {
 | 
					 | 
				
			||||||
  border-color: #ffd700;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #232526 0%, #414345 100%);
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-kira::before {
 | 
					 | 
				
			||||||
  content: '';
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: -50%;
 | 
					 | 
				
			||||||
  left: -50%;
 | 
					 | 
				
			||||||
  width: 200%;
 | 
					 | 
				
			||||||
  height: 200%;
 | 
					 | 
				
			||||||
  background: linear-gradient(
 | 
					 | 
				
			||||||
    45deg,
 | 
					 | 
				
			||||||
    transparent 30%,
 | 
					 | 
				
			||||||
    rgba(255, 215, 0, 0.1) 50%,
 | 
					 | 
				
			||||||
    transparent 70%
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  animation: shimmer 3s infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-unique {
 | 
					 | 
				
			||||||
  border-color: #ff00ff;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #000000 0%, #1a0033 100%);
 | 
					 | 
				
			||||||
  box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-unique::before {
 | 
					 | 
				
			||||||
  content: '';
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  background: radial-gradient(
 | 
					 | 
				
			||||||
    circle at center,
 | 
					 | 
				
			||||||
    transparent 0%,
 | 
					 | 
				
			||||||
    rgba(255, 0, 255, 0.2) 100%
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  animation: pulse 2s infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Card content */
 | 
					 | 
				
			||||||
.card-header {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  color: #888;
 | 
					 | 
				
			||||||
  margin-bottom: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-image-container {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  height: 150px;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  margin-bottom: 15px;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-image {
 | 
					 | 
				
			||||||
  max-width: 100%;
 | 
					 | 
				
			||||||
  max-height: 100%;
 | 
					 | 
				
			||||||
  object-fit: contain;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-content {
 | 
					 | 
				
			||||||
  flex: 1;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-name {
 | 
					 | 
				
			||||||
  font-size: 28px;
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  color: var(--card-color, #fff);
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.unique-badge {
 | 
					 | 
				
			||||||
  margin-top: 10px;
 | 
					 | 
				
			||||||
  padding: 5px 15px;
 | 
					 | 
				
			||||||
  background: linear-gradient(90deg, #ff00ff, #00ffff);
 | 
					 | 
				
			||||||
  border-radius: 20px;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  animation: glow 2s ease-in-out infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-skill {
 | 
					 | 
				
			||||||
  margin-top: 20px;
 | 
					 | 
				
			||||||
  padding: 10px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-footer {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  color: #666;
 | 
					 | 
				
			||||||
  text-transform: uppercase;
 | 
					 | 
				
			||||||
  letter-spacing: 1px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Animations */
 | 
					 | 
				
			||||||
@keyframes shimmer {
 | 
					 | 
				
			||||||
  0% { transform: translateX(-100%); }
 | 
					 | 
				
			||||||
  100% { transform: translateX(100%); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes pulse {
 | 
					 | 
				
			||||||
  0% { opacity: 0.5; }
 | 
					 | 
				
			||||||
  50% { opacity: 1; }
 | 
					 | 
				
			||||||
  100% { opacity: 0.5; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes glow {
 | 
					 | 
				
			||||||
  0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
 | 
					 | 
				
			||||||
  50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); }
 | 
					 | 
				
			||||||
  100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Simple Card Styles */
 | 
					 | 
				
			||||||
.card-simple {
 | 
					 | 
				
			||||||
  width: 240px;
 | 
					 | 
				
			||||||
  height: auto;
 | 
					 | 
				
			||||||
  background: transparent;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  padding: 0;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-frame {
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  aspect-ratio: 3/4;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
  background: #1a1a1a;
 | 
					 | 
				
			||||||
  padding: 25px 25px 30px 25px;
 | 
					 | 
				
			||||||
  border: 3px solid #666;
 | 
					 | 
				
			||||||
  box-sizing: border-box;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Normal card - no effects */
 | 
					 | 
				
			||||||
.card-simple.card-normal .card-frame {
 | 
					 | 
				
			||||||
  border-color: #666;
 | 
					 | 
				
			||||||
  background: #1a1a1a;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Unique (rare) card - glowing effects */
 | 
					 | 
				
			||||||
.card-simple.card-unique .card-frame {
 | 
					 | 
				
			||||||
  border-color: #ffd700;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  isolation: isolate;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Particle/grainy texture for rare cards */
 | 
					 | 
				
			||||||
.card-simple.card-unique .card-frame::before {
 | 
					 | 
				
			||||||
  content: "";
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  background-image: 
 | 
					 | 
				
			||||||
    repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px),
 | 
					 | 
				
			||||||
    repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px);
 | 
					 | 
				
			||||||
  background-size: 20px 20px, 30px 30px;
 | 
					 | 
				
			||||||
  opacity: 0.8;
 | 
					 | 
				
			||||||
  z-index: 1;
 | 
					 | 
				
			||||||
  pointer-events: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Reflection effect for rare cards */
 | 
					 | 
				
			||||||
.card-simple.card-unique .card-frame::after {
 | 
					 | 
				
			||||||
  content: "";
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  width: 40px;
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: -180px;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  background: linear-gradient(90deg, 
 | 
					 | 
				
			||||||
    transparent 0%,
 | 
					 | 
				
			||||||
    rgba(255, 215, 0, 0.8) 20%,
 | 
					 | 
				
			||||||
    rgba(255, 255, 0, 0.9) 40%,
 | 
					 | 
				
			||||||
    rgba(255, 223, 0, 1) 50%,
 | 
					 | 
				
			||||||
    rgba(255, 255, 0, 0.9) 60%,
 | 
					 | 
				
			||||||
    rgba(255, 215, 0, 0.8) 80%,
 | 
					 | 
				
			||||||
    transparent 100%
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  opacity: 0;
 | 
					 | 
				
			||||||
  transform: rotate(45deg);
 | 
					 | 
				
			||||||
  animation: gold-reflection 6s ease-in-out infinite;
 | 
					 | 
				
			||||||
  z-index: 2;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes gold-reflection {
 | 
					 | 
				
			||||||
  0% { transform: scale(0) rotate(45deg); opacity: 0; }
 | 
					 | 
				
			||||||
  15% { transform: scale(0) rotate(45deg); opacity: 0; }
 | 
					 | 
				
			||||||
  17% { transform: scale(4) rotate(45deg); opacity: 0.8; }
 | 
					 | 
				
			||||||
  20% { transform: scale(50) rotate(45deg); opacity: 0; }
 | 
					 | 
				
			||||||
  100% { transform: scale(50) rotate(45deg); opacity: 0; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Glowing backlight effect */
 | 
					 | 
				
			||||||
.card-simple.card-unique {
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-simple.card-unique::after {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  content: "";
 | 
					 | 
				
			||||||
  top: 5px;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  z-index: -1;
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  transform: scale(0.95);
 | 
					 | 
				
			||||||
  filter: blur(15px);
 | 
					 | 
				
			||||||
  background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%);
 | 
					 | 
				
			||||||
  opacity: 0.6;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Glowing border effect for rare cards */
 | 
					 | 
				
			||||||
.card-simple.card-unique .card-frame {
 | 
					 | 
				
			||||||
  box-shadow: 
 | 
					 | 
				
			||||||
    0 0 10px rgba(255, 215, 0, 0.5),
 | 
					 | 
				
			||||||
    inset 0 0 10px rgba(255, 215, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-image-simple {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  object-fit: cover;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  z-index: 1;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-cp-bar {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  height: 50px;
 | 
					 | 
				
			||||||
  background: #333;
 | 
					 | 
				
			||||||
  border-radius: 6px;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  margin-top: 12px;
 | 
					 | 
				
			||||||
  margin-bottom: 8px;
 | 
					 | 
				
			||||||
  border: 2px solid #666;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  box-sizing: border-box;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-simple.card-unique .card-cp-bar {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
 | 
					 | 
				
			||||||
  border-color: #ffd700;
 | 
					 | 
				
			||||||
  box-shadow: 
 | 
					 | 
				
			||||||
    0 0 5px rgba(255, 215, 0, 0.3),
 | 
					 | 
				
			||||||
    inset 0 0 5px rgba(255, 215, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.cp-value {
 | 
					 | 
				
			||||||
  font-size: 20px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  color: #fff;
 | 
					 | 
				
			||||||
  text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
 | 
					 | 
				
			||||||
  z-index: 1;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@@ -1,196 +0,0 @@
 | 
				
			|||||||
.card-box-container {
 | 
					 | 
				
			||||||
  max-width: 1200px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-box-header {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
  padding-bottom: 15px;
 | 
					 | 
				
			||||||
  border-bottom: 2px solid #e9ecef;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-box-header h3 {
 | 
					 | 
				
			||||||
  color: #495057;
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  font-size: 24px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.box-actions {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.uri-display {
 | 
					 | 
				
			||||||
  background: #e3f2fd;
 | 
					 | 
				
			||||||
  border: 1px solid #bbdefb;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 12px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.uri-display p {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  color: #1565c0;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.uri-display code {
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
  border: 1px solid #90caf9;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  padding: 4px 8px;
 | 
					 | 
				
			||||||
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  color: #0d47a1;
 | 
					 | 
				
			||||||
  word-break: break-all;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-button,
 | 
					 | 
				
			||||||
.refresh-button,
 | 
					 | 
				
			||||||
.retry-button,
 | 
					 | 
				
			||||||
.delete-button {
 | 
					 | 
				
			||||||
  padding: 8px 16px;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.retry-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.retry-button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.delete-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.delete-button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.delete-button:disabled {
 | 
					 | 
				
			||||||
  opacity: 0.6;
 | 
					 | 
				
			||||||
  cursor: not-allowed;
 | 
					 | 
				
			||||||
  transform: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-display {
 | 
					 | 
				
			||||||
  background: #f8f9fa;
 | 
					 | 
				
			||||||
  border: 1px solid #dee2e6;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-display h4 {
 | 
					 | 
				
			||||||
  color: #495057;
 | 
					 | 
				
			||||||
  margin-top: 0;
 | 
					 | 
				
			||||||
  margin-bottom: 15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.json-content {
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
  border: 1px solid #e9ecef;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  padding: 15px;
 | 
					 | 
				
			||||||
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  color: #495057;
 | 
					 | 
				
			||||||
  max-height: 400px;
 | 
					 | 
				
			||||||
  overflow-y: auto;
 | 
					 | 
				
			||||||
  white-space: pre-wrap;
 | 
					 | 
				
			||||||
  word-wrap: break-word;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.box-stats {
 | 
					 | 
				
			||||||
  background: rgba(102, 126, 234, 0.1);
 | 
					 | 
				
			||||||
  border: 1px solid #dee2e6;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 15px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.box-stats p {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  color: #495057;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-grid {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 | 
					 | 
				
			||||||
  gap: 20px;
 | 
					 | 
				
			||||||
  margin-top: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.box-card-item {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-info {
 | 
					 | 
				
			||||||
  margin-top: 8px;
 | 
					 | 
				
			||||||
  color: #6c757d;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.empty-box {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px 20px;
 | 
					 | 
				
			||||||
  color: #6c757d;
 | 
					 | 
				
			||||||
  background: #f8f9fa;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  border: 1px solid #dee2e6;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.empty-box p {
 | 
					 | 
				
			||||||
  margin: 8px 0;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.loading,
 | 
					 | 
				
			||||||
.error {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px 20px;
 | 
					 | 
				
			||||||
  color: #6c757d;
 | 
					 | 
				
			||||||
  font-size: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.error {
 | 
					 | 
				
			||||||
  color: #dc3545;
 | 
					 | 
				
			||||||
  background: #f8d7da;
 | 
					 | 
				
			||||||
  border: 1px solid #f5c6cb;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,170 +0,0 @@
 | 
				
			|||||||
.card-list-container {
 | 
					 | 
				
			||||||
  min-height: 100vh;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-header {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  margin-bottom: 40px;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-header h1 {
 | 
					 | 
				
			||||||
  color: #fff;
 | 
					 | 
				
			||||||
  margin: 0 0 10px 0;
 | 
					 | 
				
			||||||
  font-size: 2.5rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-header p {
 | 
					 | 
				
			||||||
  color: #999;
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  font-size: 1.1rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-grid {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
 | 
					 | 
				
			||||||
  gap: 30px;
 | 
					 | 
				
			||||||
  max-width: 1400px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-item {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Simple grid layout for user-page style */
 | 
					 | 
				
			||||||
.card-list-simple-grid {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 | 
					 | 
				
			||||||
  gap: 20px;
 | 
					 | 
				
			||||||
  max-width: 1400px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-list-simple-item {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.info-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #333 0%, #555 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border: 2px solid #666;
 | 
					 | 
				
			||||||
  padding: 8px 16px;
 | 
					 | 
				
			||||||
  border-radius: 6px;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  font-size: 0.9rem;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  max-width: 240px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.info-button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #444 0%, #666 100%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-info-details {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 15px;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  max-width: 240px;
 | 
					 | 
				
			||||||
  margin-top: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-info-details p {
 | 
					 | 
				
			||||||
  margin: 5px 0;
 | 
					 | 
				
			||||||
  color: #ccc;
 | 
					 | 
				
			||||||
  font-size: 0.85rem;
 | 
					 | 
				
			||||||
  text-align: left;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-info-details p strong {
 | 
					 | 
				
			||||||
  color: #fff;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-meta {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 15px;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  max-width: 250px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-meta p {
 | 
					 | 
				
			||||||
  margin: 5px 0;
 | 
					 | 
				
			||||||
  color: #ccc;
 | 
					 | 
				
			||||||
  font-size: 0.9rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-meta p:first-child {
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  color: #fff;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-description {
 | 
					 | 
				
			||||||
  font-size: 0.85rem;
 | 
					 | 
				
			||||||
  color: #999;
 | 
					 | 
				
			||||||
  font-style: italic;
 | 
					 | 
				
			||||||
  margin-top: 8px;
 | 
					 | 
				
			||||||
  line-height: 1.4;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.source-info {
 | 
					 | 
				
			||||||
  font-size: 0.9rem;
 | 
					 | 
				
			||||||
  color: #666;
 | 
					 | 
				
			||||||
  margin-top: 5px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.loading, .error {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px;
 | 
					 | 
				
			||||||
  color: #999;
 | 
					 | 
				
			||||||
  font-size: 1.2rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.error {
 | 
					 | 
				
			||||||
  color: #ff4757;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  padding: 10px 20px;
 | 
					 | 
				
			||||||
  border-radius: 5px;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  font-size: 1rem;
 | 
					 | 
				
			||||||
  margin-top: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
button:hover {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@media (max-width: 768px) {
 | 
					 | 
				
			||||||
  .card-list-grid {
 | 
					 | 
				
			||||||
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 | 
					 | 
				
			||||||
    gap: 20px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  .card-list-header h1 {
 | 
					 | 
				
			||||||
    font-size: 2rem;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,172 +0,0 @@
 | 
				
			|||||||
.collection-analysis {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  border-radius: 16px;
 | 
					 | 
				
			||||||
  padding: 24px;
 | 
					 | 
				
			||||||
  margin: 20px 0;
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.collection-analysis h3 {
 | 
					 | 
				
			||||||
  margin: 0 0 20px 0;
 | 
					 | 
				
			||||||
  font-size: 1.5rem;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.analysis-stats {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
 | 
					 | 
				
			||||||
  gap: 16px;
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stat-card {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.15);
 | 
					 | 
				
			||||||
  backdrop-filter: blur(10px);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 16px;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stat-value {
 | 
					 | 
				
			||||||
  font-size: 2rem;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  margin-bottom: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stat-label {
 | 
					 | 
				
			||||||
  font-size: 0.9rem;
 | 
					 | 
				
			||||||
  opacity: 0.8;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-distribution {
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-distribution h4 {
 | 
					 | 
				
			||||||
  margin: 0 0 16px 0;
 | 
					 | 
				
			||||||
  font-size: 1.2rem;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-bars {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-bar {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 12px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-name {
 | 
					 | 
				
			||||||
  min-width: 80px;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  text-transform: capitalize;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.bar-container {
 | 
					 | 
				
			||||||
  flex: 1;
 | 
					 | 
				
			||||||
  height: 20px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
  border-radius: 10px;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.bar {
 | 
					 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  border-radius: 10px;
 | 
					 | 
				
			||||||
  transition: width 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); }
 | 
					 | 
				
			||||||
.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); }
 | 
					 | 
				
			||||||
.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); }
 | 
					 | 
				
			||||||
.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); }
 | 
					 | 
				
			||||||
.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-count {
 | 
					 | 
				
			||||||
  min-width: 40px;
 | 
					 | 
				
			||||||
  text-align: right;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recommendations {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 16px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recommendations h4 {
 | 
					 | 
				
			||||||
  margin: 0 0 12px 0;
 | 
					 | 
				
			||||||
  font-size: 1.1rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recommendations ul {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  padding-left: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recommendations li {
 | 
					 | 
				
			||||||
  margin-bottom: 8px;
 | 
					 | 
				
			||||||
  line-height: 1.4;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-analysis,
 | 
					 | 
				
			||||||
.analyze-button,
 | 
					 | 
				
			||||||
.retry-button {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 12px 24px;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-analysis:hover,
 | 
					 | 
				
			||||||
.analyze-button:hover,
 | 
					 | 
				
			||||||
.retry-button:hover {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.analysis-loading,
 | 
					 | 
				
			||||||
.analysis-error,
 | 
					 | 
				
			||||||
.analysis-empty {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.loading-spinner {
 | 
					 | 
				
			||||||
  width: 40px;
 | 
					 | 
				
			||||||
  height: 40px;
 | 
					 | 
				
			||||||
  border: 3px solid rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  border-top: 3px solid white;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  animation: spin 1s linear infinite;
 | 
					 | 
				
			||||||
  margin: 0 auto 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes spin {
 | 
					 | 
				
			||||||
  0% { transform: rotate(0deg); }
 | 
					 | 
				
			||||||
  100% { transform: rotate(360deg); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.analysis-error p {
 | 
					 | 
				
			||||||
  color: #ffcdd2;
 | 
					 | 
				
			||||||
  margin-bottom: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.analysis-empty p {
 | 
					 | 
				
			||||||
  opacity: 0.8;
 | 
					 | 
				
			||||||
  margin-bottom: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,174 +0,0 @@
 | 
				
			|||||||
.gacha-container {
 | 
					 | 
				
			||||||
  position: fixed;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  background: rgba(0, 0, 0, 0.9);
 | 
					 | 
				
			||||||
  z-index: 1000;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-final {
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-actions {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  bottom: -80px;
 | 
					 | 
				
			||||||
  left: 50%;
 | 
					 | 
				
			||||||
  transform: translateX(-50%);
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.save-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  padding: 10px 20px;
 | 
					 | 
				
			||||||
  border-radius: 25px;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.save-button:hover:not(:disabled) {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.save-button:disabled {
 | 
					 | 
				
			||||||
  opacity: 0.6;
 | 
					 | 
				
			||||||
  cursor: not-allowed;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.click-hint {
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  background: rgba(0, 0, 0, 0.7);
 | 
					 | 
				
			||||||
  padding: 6px 12px;
 | 
					 | 
				
			||||||
  border-radius: 15px;
 | 
					 | 
				
			||||||
  animation: pulse 2s infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes pulse {
 | 
					 | 
				
			||||||
  0%, 100% { opacity: 0.7; }
 | 
					 | 
				
			||||||
  50% { opacity: 1; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.gacha-opening {
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.gacha-pack {
 | 
					 | 
				
			||||||
  width: 200px;
 | 
					 | 
				
			||||||
  height: 280px;
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  border-radius: 16px;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.pack-glow {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: -20px;
 | 
					 | 
				
			||||||
  left: -20px;
 | 
					 | 
				
			||||||
  right: -20px;
 | 
					 | 
				
			||||||
  bottom: -20px;
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
 | 
					 | 
				
			||||||
  animation: glow-pulse 2s ease-in-out infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Effect variations */
 | 
					 | 
				
			||||||
.effect-normal {
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.effect-rare {
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.effect-kira {
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.effect-kira::before {
 | 
					 | 
				
			||||||
  content: '';
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 60,40 100,50 60,60 50,100 40,60 0,50 40,40" fill="rgba(255,215,0,0.1)"/></svg>');
 | 
					 | 
				
			||||||
  background-size: 50px 50px;
 | 
					 | 
				
			||||||
  animation: sparkle 3s linear infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.effect-unique {
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%);
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.unique-effect {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  pointer-events: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.unique-particles {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  background-image: 
 | 
					 | 
				
			||||||
    radial-gradient(circle, #ff00ff 1px, transparent 1px),
 | 
					 | 
				
			||||||
    radial-gradient(circle, #00ffff 1px, transparent 1px);
 | 
					 | 
				
			||||||
  background-size: 50px 50px, 30px 30px;
 | 
					 | 
				
			||||||
  background-position: 0 0, 25px 25px;
 | 
					 | 
				
			||||||
  animation: particle-float 20s linear infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.unique-burst {
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 50%;
 | 
					 | 
				
			||||||
  left: 50%;
 | 
					 | 
				
			||||||
  width: 300px;
 | 
					 | 
				
			||||||
  height: 300px;
 | 
					 | 
				
			||||||
  transform: translate(-50%, -50%);
 | 
					 | 
				
			||||||
  background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%);
 | 
					 | 
				
			||||||
  animation: burst 1s ease-out;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Animations */
 | 
					 | 
				
			||||||
@keyframes glow-pulse {
 | 
					 | 
				
			||||||
  0%, 100% { opacity: 0.5; transform: scale(1); }
 | 
					 | 
				
			||||||
  50% { opacity: 1; transform: scale(1.1); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes sparkle {
 | 
					 | 
				
			||||||
  0% { transform: translateY(0) rotate(0deg); }
 | 
					 | 
				
			||||||
  100% { transform: translateY(-100vh) rotate(360deg); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes particle-float {
 | 
					 | 
				
			||||||
  0% { transform: translate(0, 0); }
 | 
					 | 
				
			||||||
  100% { transform: translate(-50px, -100px); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes burst {
 | 
					 | 
				
			||||||
  0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }
 | 
					 | 
				
			||||||
  100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,219 +0,0 @@
 | 
				
			|||||||
.gacha-stats {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  border-radius: 16px;
 | 
					 | 
				
			||||||
  padding: 24px;
 | 
					 | 
				
			||||||
  margin: 20px 0;
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.gacha-stats h3 {
 | 
					 | 
				
			||||||
  margin: 0 0 20px 0;
 | 
					 | 
				
			||||||
  font-size: 1.5rem;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stats-overview {
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.overview-card {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.15);
 | 
					 | 
				
			||||||
  backdrop-filter: blur(10px);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
  display: inline-block;
 | 
					 | 
				
			||||||
  min-width: 200px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.overview-value {
 | 
					 | 
				
			||||||
  font-size: 2.5rem;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  margin-bottom: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.overview-label {
 | 
					 | 
				
			||||||
  font-size: 1rem;
 | 
					 | 
				
			||||||
  opacity: 0.9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-stats {
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-stats h4 {
 | 
					 | 
				
			||||||
  margin: 0 0 16px 0;
 | 
					 | 
				
			||||||
  font-size: 1.2rem;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-grid {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
 | 
					 | 
				
			||||||
  gap: 12px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-stat {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.15);
 | 
					 | 
				
			||||||
  backdrop-filter: blur(10px);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 16px;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-stat::before {
 | 
					 | 
				
			||||||
  content: '';
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  height: 3px;
 | 
					 | 
				
			||||||
  background: var(--rarity-color);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-stat.rarity-common { --rarity-color: #4CAF50; }
 | 
					 | 
				
			||||||
.rarity-stat.rarity-rare { --rarity-color: #2196F3; }
 | 
					 | 
				
			||||||
.rarity-stat.rarity-epic { --rarity-color: #9C27B0; }
 | 
					 | 
				
			||||||
.rarity-stat.rarity-legendary { --rarity-color: #FF9800; }
 | 
					 | 
				
			||||||
.rarity-stat.rarity-mythic { --rarity-color: #F44336; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-count {
 | 
					 | 
				
			||||||
  font-size: 1.8rem;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  margin-bottom: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.rarity-name {
 | 
					 | 
				
			||||||
  font-size: 0.9rem;
 | 
					 | 
				
			||||||
  opacity: 0.9;
 | 
					 | 
				
			||||||
  text-transform: capitalize;
 | 
					 | 
				
			||||||
  margin-bottom: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.success-rate {
 | 
					 | 
				
			||||||
  font-size: 0.8rem;
 | 
					 | 
				
			||||||
  opacity: 0.7;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  padding: 2px 6px;
 | 
					 | 
				
			||||||
  display: inline-block;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recent-activity {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 16px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.recent-activity h4 {
 | 
					 | 
				
			||||||
  margin: 0 0 12px 0;
 | 
					 | 
				
			||||||
  font-size: 1.1rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.activity-list {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.activity-item {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 12px;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.activity-time {
 | 
					 | 
				
			||||||
  font-size: 0.8rem;
 | 
					 | 
				
			||||||
  opacity: 0.7;
 | 
					 | 
				
			||||||
  min-width: 120px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.activity-details {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					 | 
				
			||||||
  flex: 1;
 | 
					 | 
				
			||||||
  justify-content: flex-end;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-rarity {
 | 
					 | 
				
			||||||
  padding: 2px 8px;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  font-size: 0.75rem;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  text-transform: uppercase;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-rarity.rarity-common { background: #4CAF50; }
 | 
					 | 
				
			||||||
.card-rarity.rarity-rare { background: #2196F3; }
 | 
					 | 
				
			||||||
.card-rarity.rarity-epic { background: #9C27B0; }
 | 
					 | 
				
			||||||
.card-rarity.rarity-legendary { background: #FF9800; }
 | 
					 | 
				
			||||||
.card-rarity.rarity-mythic { background: #F44336; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card-name {
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-stats,
 | 
					 | 
				
			||||||
.load-stats-button,
 | 
					 | 
				
			||||||
.retry-button {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.2);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 12px 24px;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.refresh-stats:hover,
 | 
					 | 
				
			||||||
.load-stats-button:hover,
 | 
					 | 
				
			||||||
.retry-button:hover {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stats-loading,
 | 
					 | 
				
			||||||
.stats-error,
 | 
					 | 
				
			||||||
.stats-empty {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  padding: 40px 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.loading-spinner {
 | 
					 | 
				
			||||||
  width: 40px;
 | 
					 | 
				
			||||||
  height: 40px;
 | 
					 | 
				
			||||||
  border: 3px solid rgba(255, 255, 255, 0.3);
 | 
					 | 
				
			||||||
  border-top: 3px solid white;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  animation: spin 1s linear infinite;
 | 
					 | 
				
			||||||
  margin: 0 auto 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes spin {
 | 
					 | 
				
			||||||
  0% { transform: rotate(0deg); }
 | 
					 | 
				
			||||||
  100% { transform: rotate(360deg); }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stats-error p {
 | 
					 | 
				
			||||||
  color: #ffcdd2;
 | 
					 | 
				
			||||||
  margin-bottom: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.stats-empty p {
 | 
					 | 
				
			||||||
  opacity: 0.8;
 | 
					 | 
				
			||||||
  margin-bottom: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,243 +0,0 @@
 | 
				
			|||||||
.login-overlay {
 | 
					 | 
				
			||||||
  position: fixed;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  background: rgba(0, 0, 0, 0.8);
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  z-index: 1000;
 | 
					 | 
				
			||||||
  backdrop-filter: blur(5px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-modal {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
 | 
					 | 
				
			||||||
  border: 1px solid #444;
 | 
					 | 
				
			||||||
  border-radius: 16px;
 | 
					 | 
				
			||||||
  padding: 40px;
 | 
					 | 
				
			||||||
  max-width: 450px;
 | 
					 | 
				
			||||||
  width: 90%;
 | 
					 | 
				
			||||||
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-mode-selector {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.05);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.mode-button {
 | 
					 | 
				
			||||||
  flex: 1;
 | 
					 | 
				
			||||||
  padding: 12px 16px;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  background: transparent;
 | 
					 | 
				
			||||||
  color: #ccc;
 | 
					 | 
				
			||||||
  border-radius: 6px;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.mode-button.active {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.mode-button:hover:not(.active) {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-login {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-info {
 | 
					 | 
				
			||||||
  margin-bottom: 24px;
 | 
					 | 
				
			||||||
  padding: 20px;
 | 
					 | 
				
			||||||
  background: rgba(102, 126, 234, 0.1);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  border: 1px solid rgba(102, 126, 234, 0.3);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-info h3 {
 | 
					 | 
				
			||||||
  margin: 0 0 12px 0;
 | 
					 | 
				
			||||||
  font-size: 18px;
 | 
					 | 
				
			||||||
  color: #667eea;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-info p {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  line-height: 1.5;
 | 
					 | 
				
			||||||
  opacity: 0.9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-login-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  padding: 16px 32px;
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  font-size: 16px;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
  box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-login-button:hover:not(:disabled) {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.oauth-login-button:disabled {
 | 
					 | 
				
			||||||
  opacity: 0.7;
 | 
					 | 
				
			||||||
  cursor: not-allowed;
 | 
					 | 
				
			||||||
  transform: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-modal h2 {
 | 
					 | 
				
			||||||
  margin: 0 0 30px 0;
 | 
					 | 
				
			||||||
  font-size: 28px;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
 | 
					 | 
				
			||||||
  -webkit-background-clip: text;
 | 
					 | 
				
			||||||
  -webkit-text-fill-color: transparent;
 | 
					 | 
				
			||||||
  background-clip: text;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group {
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group label {
 | 
					 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
  margin-bottom: 8px;
 | 
					 | 
				
			||||||
  color: #ccc;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group input {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  padding: 12px 16px;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  border: 1px solid #444;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  font-size: 16px;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group input:focus {
 | 
					 | 
				
			||||||
  outline: none;
 | 
					 | 
				
			||||||
  border-color: #fff700;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.15);
 | 
					 | 
				
			||||||
  box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group input:disabled {
 | 
					 | 
				
			||||||
  opacity: 0.5;
 | 
					 | 
				
			||||||
  cursor: not-allowed;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group small {
 | 
					 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
  margin-top: 6px;
 | 
					 | 
				
			||||||
  color: #888;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group small a {
 | 
					 | 
				
			||||||
  color: #fff700;
 | 
					 | 
				
			||||||
  text-decoration: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form-group small a:hover {
 | 
					 | 
				
			||||||
  text-decoration: underline;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.error-message {
 | 
					 | 
				
			||||||
  background: rgba(255, 71, 87, 0.1);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 71, 87, 0.3);
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  padding: 12px;
 | 
					 | 
				
			||||||
  margin-bottom: 20px;
 | 
					 | 
				
			||||||
  color: #ff4757;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.button-group {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  gap: 12px;
 | 
					 | 
				
			||||||
  margin-top: 30px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-button,
 | 
					 | 
				
			||||||
.cancel-button {
 | 
					 | 
				
			||||||
  flex: 1;
 | 
					 | 
				
			||||||
  padding: 14px 24px;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  border-radius: 8px;
 | 
					 | 
				
			||||||
  font-size: 16px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  transition: all 0.3s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-button {
 | 
					 | 
				
			||||||
  background: linear-gradient(135deg, #fff700 0%, #ffd700 100%);
 | 
					 | 
				
			||||||
  color: #000;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-button:hover:not(:disabled) {
 | 
					 | 
				
			||||||
  transform: translateY(-2px);
 | 
					 | 
				
			||||||
  box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-button:disabled {
 | 
					 | 
				
			||||||
  opacity: 0.5;
 | 
					 | 
				
			||||||
  cursor: not-allowed;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.cancel-button {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.1);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border: 1px solid #444;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.cancel-button:hover:not(:disabled) {
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.15);
 | 
					 | 
				
			||||||
  border-color: #666;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-info {
 | 
					 | 
				
			||||||
  margin-top: 30px;
 | 
					 | 
				
			||||||
  padding-top: 20px;
 | 
					 | 
				
			||||||
  border-top: 1px solid #333;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.login-info p {
 | 
					 | 
				
			||||||
  color: #888;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
  line-height: 1.6;
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dev-notice {
 | 
					 | 
				
			||||||
  background: rgba(255, 193, 7, 0.1);
 | 
					 | 
				
			||||||
  border: 1px solid rgba(255, 193, 7, 0.3);
 | 
					 | 
				
			||||||
  border-radius: 6px;
 | 
					 | 
				
			||||||
  padding: 8px 12px;
 | 
					 | 
				
			||||||
  margin: 10px 0;
 | 
					 | 
				
			||||||
  color: #ffc107;
 | 
					 | 
				
			||||||
  font-size: 12px;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,135 +0,0 @@
 | 
				
			|||||||
// Simple console test for OAuth app
 | 
					 | 
				
			||||||
// This runs before 'npm run preview' to display test results
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Mock import.meta.env for Node.js environment
 | 
					 | 
				
			||||||
(global as any).import = {
 | 
					 | 
				
			||||||
  meta: {
 | 
					 | 
				
			||||||
    env: {
 | 
					 | 
				
			||||||
      VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
 | 
					 | 
				
			||||||
      VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
 | 
					 | 
				
			||||||
      VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
 | 
					 | 
				
			||||||
      VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
 | 
					 | 
				
			||||||
      VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
 | 
					 | 
				
			||||||
      VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Simple implementation of functions for testing
 | 
					 | 
				
			||||||
function detectPdsFromHandle(handle: string): string {
 | 
					 | 
				
			||||||
  if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
 | 
					 | 
				
			||||||
    return 'syu.is';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (handle.endsWith('.bsky.social')) {
 | 
					 | 
				
			||||||
    return 'bsky.social';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  // Default case - check if it's in the allowed list
 | 
					 | 
				
			||||||
  const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
 | 
					 | 
				
			||||||
  if (allowedHandles.includes(handle)) {
 | 
					 | 
				
			||||||
    return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return 'bsky.social';
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function getNetworkConfig(pds: string) {
 | 
					 | 
				
			||||||
  switch (pds) {
 | 
					 | 
				
			||||||
    case 'bsky.social':
 | 
					 | 
				
			||||||
    case 'bsky.app':
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: `https://${pds}`,
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.directory',
 | 
					 | 
				
			||||||
        bskyApi: 'https://public.api.bsky.app',
 | 
					 | 
				
			||||||
        webUrl: 'https://bsky.app'
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    case 'syu.is':
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: 'https://syu.is',
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.syu.is',
 | 
					 | 
				
			||||||
        bskyApi: 'https://bsky.syu.is',
 | 
					 | 
				
			||||||
        webUrl: 'https://web.syu.is'
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    default:
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: `https://${pds}`,
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.directory',
 | 
					 | 
				
			||||||
        bskyApi: 'https://public.api.bsky.app',
 | 
					 | 
				
			||||||
        webUrl: 'https://bsky.app'
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Main test execution
 | 
					 | 
				
			||||||
console.log('\n=== OAuth App Configuration Tests ===\n');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 1: Handle input behavior
 | 
					 | 
				
			||||||
console.log('1. Handle Input → PDS Detection:');
 | 
					 | 
				
			||||||
const testHandles = [
 | 
					 | 
				
			||||||
  'syui.ai',
 | 
					 | 
				
			||||||
  'syui.syu.is', 
 | 
					 | 
				
			||||||
  'syui.syui.ai',
 | 
					 | 
				
			||||||
  'test.bsky.social',
 | 
					 | 
				
			||||||
  'unknown.handle'
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
testHandles.forEach(handle => {
 | 
					 | 
				
			||||||
  const pds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
  const config = getNetworkConfig(pds);
 | 
					 | 
				
			||||||
  console.log(`   ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 2: Environment variable impact
 | 
					 | 
				
			||||||
console.log('\n2. Current Environment Configuration:');
 | 
					 | 
				
			||||||
const env = (global as any).import.meta.env;
 | 
					 | 
				
			||||||
console.log(`   VITE_ATPROTO_PDS:      ${env.VITE_ATPROTO_PDS}`);
 | 
					 | 
				
			||||||
console.log(`   VITE_ADMIN_HANDLE:     ${env.VITE_ADMIN_HANDLE}`);
 | 
					 | 
				
			||||||
console.log(`   VITE_AI_HANDLE:        ${env.VITE_AI_HANDLE}`);
 | 
					 | 
				
			||||||
console.log(`   VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
 | 
					 | 
				
			||||||
console.log(`   VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 3: API endpoint generation
 | 
					 | 
				
			||||||
console.log('\n3. Generated API Endpoints:');
 | 
					 | 
				
			||||||
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
 | 
					 | 
				
			||||||
const adminConfig = getNetworkConfig(adminPds);
 | 
					 | 
				
			||||||
console.log(`   Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
 | 
					 | 
				
			||||||
console.log(`   Admin API endpoints:`);
 | 
					 | 
				
			||||||
console.log(`     - PDS API:  ${adminConfig.pdsApi}`);
 | 
					 | 
				
			||||||
console.log(`     - Bsky API: ${adminConfig.bskyApi}`);
 | 
					 | 
				
			||||||
console.log(`     - Web URL:  ${adminConfig.webUrl}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 4: Collection URLs
 | 
					 | 
				
			||||||
console.log('\n4. Collection API URLs:');
 | 
					 | 
				
			||||||
const baseCollection = env.VITE_OAUTH_COLLECTION;
 | 
					 | 
				
			||||||
console.log(`   User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
 | 
					 | 
				
			||||||
console.log(`   Chat:      ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
 | 
					 | 
				
			||||||
console.log(`   Lang:      ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
 | 
					 | 
				
			||||||
console.log(`   Comment:   ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 5: OAuth routing logic
 | 
					 | 
				
			||||||
console.log('\n5. OAuth Authorization Logic:');
 | 
					 | 
				
			||||||
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
 | 
					 | 
				
			||||||
console.log(`   Allowed handles: ${JSON.stringify(allowedHandles)}`);
 | 
					 | 
				
			||||||
console.log(`   OAuth scenarios:`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const oauthTestCases = [
 | 
					 | 
				
			||||||
  'syui.ai',         // Should use syu.is (in allowed list)
 | 
					 | 
				
			||||||
  'test.syu.is',     // Should use syu.is (*.syu.is pattern)
 | 
					 | 
				
			||||||
  'user.bsky.social' // Should use bsky.social (default)
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
oauthTestCases.forEach(handle => {
 | 
					 | 
				
			||||||
  const pds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
  const isAllowed = allowedHandles.includes(handle);
 | 
					 | 
				
			||||||
  const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' : 
 | 
					 | 
				
			||||||
                 isAllowed ? 'in allowed list' : 
 | 
					 | 
				
			||||||
                 'default';
 | 
					 | 
				
			||||||
  console.log(`     ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test 6: AI Profile Resolution
 | 
					 | 
				
			||||||
console.log('\n6. AI Profile Resolution:');
 | 
					 | 
				
			||||||
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
 | 
					 | 
				
			||||||
const aiConfig = getNetworkConfig(aiPds);
 | 
					 | 
				
			||||||
console.log(`   AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
 | 
					 | 
				
			||||||
console.log(`   AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
console.log('\n=== Tests Complete ===\n');
 | 
					 | 
				
			||||||
@@ -1,141 +0,0 @@
 | 
				
			|||||||
import { describe, it, expect, beforeEach } from 'vitest';
 | 
					 | 
				
			||||||
import { getAppConfig } from '../config/app';
 | 
					 | 
				
			||||||
import { detectPdsFromHandle, getNetworkConfig } from '../App';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test helper to mock environment variables
 | 
					 | 
				
			||||||
const mockEnv = (vars: Record<string, string>) => {
 | 
					 | 
				
			||||||
  Object.keys(vars).forEach(key => {
 | 
					 | 
				
			||||||
    (import.meta.env as any)[key] = vars[key];
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('OAuth App Tests', () => {
 | 
					 | 
				
			||||||
  describe('Handle Input Behavior', () => {
 | 
					 | 
				
			||||||
    it('should detect PDS for syui.ai (Bluesky)', () => {
 | 
					 | 
				
			||||||
      const pds = detectPdsFromHandle('syui.ai');
 | 
					 | 
				
			||||||
      expect(pds).toBe('bsky.social');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should detect PDS for syui.syu.is (syu.is)', () => {
 | 
					 | 
				
			||||||
      const pds = detectPdsFromHandle('syui.syu.is');
 | 
					 | 
				
			||||||
      expect(pds).toBe('syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should detect PDS for syui.syui.ai (syu.is)', () => {
 | 
					 | 
				
			||||||
      const pds = detectPdsFromHandle('syui.syui.ai');
 | 
					 | 
				
			||||||
      expect(pds).toBe('syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should use network config for different PDS', () => {
 | 
					 | 
				
			||||||
      const bskyConfig = getNetworkConfig('bsky.social');
 | 
					 | 
				
			||||||
      expect(bskyConfig.pdsApi).toBe('https://bsky.social');
 | 
					 | 
				
			||||||
      expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
 | 
					 | 
				
			||||||
      expect(bskyConfig.webUrl).toBe('https://bsky.app');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const syuisConfig = getNetworkConfig('syu.is');
 | 
					 | 
				
			||||||
      expect(syuisConfig.pdsApi).toBe('https://syu.is');
 | 
					 | 
				
			||||||
      expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
 | 
					 | 
				
			||||||
      expect(syuisConfig.webUrl).toBe('https://web.syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('Environment Variable Changes', () => {
 | 
					 | 
				
			||||||
    beforeEach(() => {
 | 
					 | 
				
			||||||
      // Reset environment variables
 | 
					 | 
				
			||||||
      delete (import.meta.env as any).VITE_ATPROTO_PDS;
 | 
					 | 
				
			||||||
      delete (import.meta.env as any).VITE_ADMIN_HANDLE;
 | 
					 | 
				
			||||||
      delete (import.meta.env as any).VITE_AI_HANDLE;
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should use correct PDS for AI profile', () => {
 | 
					 | 
				
			||||||
      mockEnv({
 | 
					 | 
				
			||||||
        VITE_ATPROTO_PDS: 'syu.is',
 | 
					 | 
				
			||||||
        VITE_ADMIN_HANDLE: 'ai.syui.ai',
 | 
					 | 
				
			||||||
        VITE_AI_HANDLE: 'ai.syui.ai'
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const config = getAppConfig();
 | 
					 | 
				
			||||||
      expect(config.atprotoPds).toBe('syu.is');
 | 
					 | 
				
			||||||
      expect(config.adminHandle).toBe('ai.syui.ai');
 | 
					 | 
				
			||||||
      expect(config.aiHandle).toBe('ai.syui.ai');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Network config should use syu.is endpoints
 | 
					 | 
				
			||||||
      const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
					 | 
				
			||||||
      expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should construct correct API requests for admin userlist', () => {
 | 
					 | 
				
			||||||
      mockEnv({
 | 
					 | 
				
			||||||
        VITE_ATPROTO_PDS: 'syu.is',
 | 
					 | 
				
			||||||
        VITE_ADMIN_HANDLE: 'ai.syui.ai',
 | 
					 | 
				
			||||||
        VITE_OAUTH_COLLECTION: 'ai.syui.log'
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const config = getAppConfig();
 | 
					 | 
				
			||||||
      const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
					 | 
				
			||||||
      const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('OAuth Login Flow', () => {
 | 
					 | 
				
			||||||
    it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
 | 
					 | 
				
			||||||
      mockEnv({
 | 
					 | 
				
			||||||
        VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
 | 
					 | 
				
			||||||
        VITE_ATPROTO_PDS: 'syu.is'
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const config = getAppConfig();
 | 
					 | 
				
			||||||
      const handle = 'syui.ai';
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Check if handle is in allowed list
 | 
					 | 
				
			||||||
      expect(config.allowedHandles).toContain(handle);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Should use configured PDS for OAuth
 | 
					 | 
				
			||||||
      const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
 | 
					 | 
				
			||||||
      expect(expectedAuthUrl).toContain('syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should use syu.is OAuth for *.syu.is handles', () => {
 | 
					 | 
				
			||||||
      const handle = 'test.syu.is';
 | 
					 | 
				
			||||||
      const pds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
      expect(pds).toBe('syu.is');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Terminal display test output
 | 
					 | 
				
			||||||
export function runTerminalTests() {
 | 
					 | 
				
			||||||
  console.log('\n=== OAuth App Tests ===\n');
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Test 1: Handle input behavior
 | 
					 | 
				
			||||||
  console.log('1. Handle Input Detection:');
 | 
					 | 
				
			||||||
  const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
 | 
					 | 
				
			||||||
  handles.forEach(handle => {
 | 
					 | 
				
			||||||
    const pds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
    console.log(`   ${handle} → PDS: ${pds}`);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Test 2: Environment variable impact
 | 
					 | 
				
			||||||
  console.log('\n2. Environment Variables:');
 | 
					 | 
				
			||||||
  const config = getAppConfig();
 | 
					 | 
				
			||||||
  console.log(`   VITE_ATPROTO_PDS: ${config.atprotoPds}`);
 | 
					 | 
				
			||||||
  console.log(`   VITE_ADMIN_HANDLE: ${config.adminHandle}`);
 | 
					 | 
				
			||||||
  console.log(`   VITE_AI_HANDLE: ${config.aiHandle}`);
 | 
					 | 
				
			||||||
  console.log(`   VITE_OAUTH_COLLECTION: ${config.collections.base}`);
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Test 3: API endpoints
 | 
					 | 
				
			||||||
  console.log('\n3. API Endpoints:');
 | 
					 | 
				
			||||||
  const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
					 | 
				
			||||||
  console.log(`   Admin PDS API: ${networkConfig.pdsApi}`);
 | 
					 | 
				
			||||||
  console.log(`   Admin Bsky API: ${networkConfig.bskyApi}`);
 | 
					 | 
				
			||||||
  console.log(`   User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Test 4: OAuth routing
 | 
					 | 
				
			||||||
  console.log('\n4. OAuth Routing:');
 | 
					 | 
				
			||||||
  console.log(`   Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
 | 
					 | 
				
			||||||
  console.log(`   OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  console.log('\n=== End Tests ===\n');
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,24 +0,0 @@
 | 
				
			|||||||
export enum CardRarity {
 | 
					 | 
				
			||||||
  NORMAL = "normal",
 | 
					 | 
				
			||||||
  RARE = "rare",
 | 
					 | 
				
			||||||
  SUPER_RARE = "super_rare",
 | 
					 | 
				
			||||||
  KIRA = "kira",
 | 
					 | 
				
			||||||
  UNIQUE = "unique"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface Card {
 | 
					 | 
				
			||||||
  id: number;
 | 
					 | 
				
			||||||
  cp: number;
 | 
					 | 
				
			||||||
  status: CardRarity;
 | 
					 | 
				
			||||||
  skill?: string;
 | 
					 | 
				
			||||||
  owner_did: string;
 | 
					 | 
				
			||||||
  obtained_at: string;
 | 
					 | 
				
			||||||
  is_unique: boolean;
 | 
					 | 
				
			||||||
  unique_id?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface CardDrawResult {
 | 
					 | 
				
			||||||
  card: Card;
 | 
					 | 
				
			||||||
  is_new: boolean;
 | 
					 | 
				
			||||||
  animation_type: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,138 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * OAuth dynamic endpoint handlers
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
import { OAuthKeyManager, generateClientMetadata } from './oauth-keys';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class OAuthEndpointHandler {
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Initialize OAuth endpoint handlers
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static init() {
 | 
					 | 
				
			||||||
    // Intercept requests to client-metadata.json
 | 
					 | 
				
			||||||
    this.setupClientMetadataHandler();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Intercept requests to .well-known/jwks.json
 | 
					 | 
				
			||||||
    this.setupJWKSHandler();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private static setupClientMetadataHandler() {
 | 
					 | 
				
			||||||
    // Override fetch for client-metadata.json requests
 | 
					 | 
				
			||||||
    const originalFetch = window.fetch;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
 | 
					 | 
				
			||||||
      const url = typeof input === 'string' ? input : input.toString();
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Only intercept local OAuth endpoints
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const urlObj = new URL(url, window.location.origin);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Only intercept requests to the same origin
 | 
					 | 
				
			||||||
        if (urlObj.origin !== window.location.origin) {
 | 
					 | 
				
			||||||
          // Pass through external API calls unchanged
 | 
					 | 
				
			||||||
          return originalFetch(input, init);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Handle local OAuth endpoints
 | 
					 | 
				
			||||||
        if (urlObj.pathname.endsWith('/client-metadata.json')) {
 | 
					 | 
				
			||||||
          const metadata = generateClientMetadata();
 | 
					 | 
				
			||||||
          return new Response(JSON.stringify(metadata, null, 2), {
 | 
					 | 
				
			||||||
            headers: {
 | 
					 | 
				
			||||||
              'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
              'Access-Control-Allow-Origin': '*'
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (urlObj.pathname.endsWith('/.well-known/jwks.json')) {
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            const jwks = await OAuthKeyManager.getJWKS();
 | 
					 | 
				
			||||||
            return new Response(JSON.stringify(jwks, null, 2), {
 | 
					 | 
				
			||||||
              headers: {
 | 
					 | 
				
			||||||
                'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
                'Access-Control-Allow-Origin': '*'
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          } catch (error) {
 | 
					 | 
				
			||||||
            return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
 | 
					 | 
				
			||||||
              status: 500,
 | 
					 | 
				
			||||||
              headers: { 'Content-Type': 'application/json' }
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					 | 
				
			||||||
        // If URL parsing fails, pass through to original fetch
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Pass through all other requests
 | 
					 | 
				
			||||||
      return originalFetch(input, init);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private static setupJWKSHandler() {
 | 
					 | 
				
			||||||
    // This is handled in the fetch override above
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Generate a proper client assertion JWT for token requests
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static async generateClientAssertion(tokenEndpoint: string): Promise<string> {
 | 
					 | 
				
			||||||
    const now = Math.floor(Date.now() / 1000);
 | 
					 | 
				
			||||||
    const clientId = generateClientMetadata().client_id;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const header = {
 | 
					 | 
				
			||||||
      alg: 'ES256',
 | 
					 | 
				
			||||||
      typ: 'JWT',
 | 
					 | 
				
			||||||
      kid: 'ai-card-oauth-key-1'
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const payload = {
 | 
					 | 
				
			||||||
      iss: clientId,
 | 
					 | 
				
			||||||
      sub: clientId,
 | 
					 | 
				
			||||||
      aud: tokenEndpoint,
 | 
					 | 
				
			||||||
      iat: now,
 | 
					 | 
				
			||||||
      exp: now + 300, // 5 minutes
 | 
					 | 
				
			||||||
      jti: crypto.randomUUID()
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return await OAuthKeyManager.signJWT(header, payload);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Service Worker alternative for intercepting requests
 | 
					 | 
				
			||||||
 * (This is a more robust solution for production)
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export function registerOAuthServiceWorker() {
 | 
					 | 
				
			||||||
  if ('serviceWorker' in navigator) {
 | 
					 | 
				
			||||||
    const swCode = `
 | 
					 | 
				
			||||||
      self.addEventListener('fetch', (event) => {
 | 
					 | 
				
			||||||
        const url = new URL(event.request.url);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (url.pathname.endsWith('/client-metadata.json')) {
 | 
					 | 
				
			||||||
          event.respondWith(
 | 
					 | 
				
			||||||
            new Response(JSON.stringify({
 | 
					 | 
				
			||||||
              client_id: url.origin + '/client-metadata.json',
 | 
					 | 
				
			||||||
              client_name: 'ai.card',
 | 
					 | 
				
			||||||
              client_uri: url.origin,
 | 
					 | 
				
			||||||
              redirect_uris: [url.origin + '/oauth/callback'],
 | 
					 | 
				
			||||||
              response_types: ['code'],
 | 
					 | 
				
			||||||
              grant_types: ['authorization_code', 'refresh_token'],
 | 
					 | 
				
			||||||
              token_endpoint_auth_method: 'private_key_jwt',
 | 
					 | 
				
			||||||
              scope: 'atproto transition:generic',
 | 
					 | 
				
			||||||
              subject_type: 'public',
 | 
					 | 
				
			||||||
              application_type: 'web',
 | 
					 | 
				
			||||||
              dpop_bound_access_tokens: true,
 | 
					 | 
				
			||||||
              jwks_uri: url.origin + '/.well-known/jwks.json'
 | 
					 | 
				
			||||||
            }, null, 2), {
 | 
					 | 
				
			||||||
              headers: { 'Content-Type': 'application/json' }
 | 
					 | 
				
			||||||
            })
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    `;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const blob = new Blob([swCode], { type: 'application/javascript' });
 | 
					 | 
				
			||||||
    const swUrl = URL.createObjectURL(blob);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,181 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * OAuth JWKS key generation and management
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface JWK {
 | 
					 | 
				
			||||||
  kty: string;
 | 
					 | 
				
			||||||
  crv: string;
 | 
					 | 
				
			||||||
  x: string;
 | 
					 | 
				
			||||||
  y: string;
 | 
					 | 
				
			||||||
  d?: string;
 | 
					 | 
				
			||||||
  use: string;
 | 
					 | 
				
			||||||
  kid: string;
 | 
					 | 
				
			||||||
  alg: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface JWKS {
 | 
					 | 
				
			||||||
  keys: JWK[];
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class OAuthKeyManager {
 | 
					 | 
				
			||||||
  private static keyPair: CryptoKeyPair | null = null;
 | 
					 | 
				
			||||||
  private static jwks: JWKS | null = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Generate or retrieve existing ECDSA key pair for OAuth
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static async getKeyPair(): Promise<CryptoKeyPair> {
 | 
					 | 
				
			||||||
    if (this.keyPair) {
 | 
					 | 
				
			||||||
      return this.keyPair;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Try to load from localStorage first
 | 
					 | 
				
			||||||
    const storedKey = localStorage.getItem('oauth_private_key');
 | 
					 | 
				
			||||||
    if (storedKey) {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const keyData = JSON.parse(storedKey);
 | 
					 | 
				
			||||||
        this.keyPair = await this.importKeyPair(keyData);
 | 
					 | 
				
			||||||
        return this.keyPair;
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        localStorage.removeItem('oauth_private_key');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Generate new key pair
 | 
					 | 
				
			||||||
    this.keyPair = await window.crypto.subtle.generateKey(
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        name: 'ECDSA',
 | 
					 | 
				
			||||||
        namedCurve: 'P-256',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      true, // extractable
 | 
					 | 
				
			||||||
      ['sign', 'verify']
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Store private key for persistence
 | 
					 | 
				
			||||||
    await this.storeKeyPair(this.keyPair);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return this.keyPair;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Get JWKS (JSON Web Key Set) for public key distribution
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static async getJWKS(): Promise<JWKS> {
 | 
					 | 
				
			||||||
    if (this.jwks) {
 | 
					 | 
				
			||||||
      return this.jwks;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const keyPair = await this.getKeyPair();
 | 
					 | 
				
			||||||
    const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.jwks = {
 | 
					 | 
				
			||||||
      keys: [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          kty: publicKey.kty!,
 | 
					 | 
				
			||||||
          crv: publicKey.crv!,
 | 
					 | 
				
			||||||
          x: publicKey.x!,
 | 
					 | 
				
			||||||
          y: publicKey.y!,
 | 
					 | 
				
			||||||
          use: 'sig',
 | 
					 | 
				
			||||||
          kid: 'ai-card-oauth-key-1',
 | 
					 | 
				
			||||||
          alg: 'ES256'
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      ]
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return this.jwks;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Sign a JWT with the private key
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static async signJWT(header: any, payload: any): Promise<string> {
 | 
					 | 
				
			||||||
    const keyPair = await this.getKeyPair();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
 | 
					 | 
				
			||||||
    const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
 | 
					 | 
				
			||||||
    const message = `${headerB64}.${payloadB64}`;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const signature = await window.crypto.subtle.sign(
 | 
					 | 
				
			||||||
      { name: 'ECDSA', hash: 'SHA-256' },
 | 
					 | 
				
			||||||
      keyPair.privateKey,
 | 
					 | 
				
			||||||
      new TextEncoder().encode(message)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
 | 
					 | 
				
			||||||
      .replace(/\+/g, '-')
 | 
					 | 
				
			||||||
      .replace(/\//g, '_')
 | 
					 | 
				
			||||||
      .replace(/=/g, '');
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return `${message}.${signatureB64}`;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
 | 
					 | 
				
			||||||
      localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
 | 
					 | 
				
			||||||
    const privateKey = await window.crypto.subtle.importKey(
 | 
					 | 
				
			||||||
      'jwk',
 | 
					 | 
				
			||||||
      keyData,
 | 
					 | 
				
			||||||
      { name: 'ECDSA', namedCurve: 'P-256' },
 | 
					 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
      ['sign']
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Derive public key from private key
 | 
					 | 
				
			||||||
    const publicKeyData = { ...keyData };
 | 
					 | 
				
			||||||
    delete publicKeyData.d; // Remove private component
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const publicKey = await window.crypto.subtle.importKey(
 | 
					 | 
				
			||||||
      'jwk',
 | 
					 | 
				
			||||||
      publicKeyData,
 | 
					 | 
				
			||||||
      { name: 'ECDSA', namedCurve: 'P-256' },
 | 
					 | 
				
			||||||
      true,
 | 
					 | 
				
			||||||
      ['verify']
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return { privateKey, publicKey };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Clear stored keys (for testing/reset)
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  static clearKeys(): void {
 | 
					 | 
				
			||||||
    localStorage.removeItem('oauth_private_key');
 | 
					 | 
				
			||||||
    this.keyPair = null;
 | 
					 | 
				
			||||||
    this.jwks = null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Generate dynamic client metadata based on current URL
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export function generateClientMetadata(): any {
 | 
					 | 
				
			||||||
  // Use environment variables if available, fallback to current origin
 | 
					 | 
				
			||||||
  const host = import.meta.env.VITE_APP_HOST || window.location.origin;
 | 
					 | 
				
			||||||
  const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
 | 
					 | 
				
			||||||
  const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    client_id: clientId,
 | 
					 | 
				
			||||||
    client_name: 'ai.card',
 | 
					 | 
				
			||||||
    client_uri: host,
 | 
					 | 
				
			||||||
    logo_uri: `${host}/favicon.ico`,
 | 
					 | 
				
			||||||
    tos_uri: `${host}/terms`,
 | 
					 | 
				
			||||||
    policy_uri: `${host}/privacy`,
 | 
					 | 
				
			||||||
    redirect_uris: [redirectUri, host],
 | 
					 | 
				
			||||||
    response_types: ['code'],
 | 
					 | 
				
			||||||
    grant_types: ['authorization_code', 'refresh_token'],
 | 
					 | 
				
			||||||
    token_endpoint_auth_method: 'private_key_jwt',
 | 
					 | 
				
			||||||
    token_endpoint_auth_signing_alg: 'ES256',
 | 
					 | 
				
			||||||
    scope: 'atproto transition:generic',
 | 
					 | 
				
			||||||
    subject_type: 'public',
 | 
					 | 
				
			||||||
    application_type: 'web',
 | 
					 | 
				
			||||||
    dpop_bound_access_tokens: true,
 | 
					 | 
				
			||||||
    jwks_uri: `${host}/.well-known/jwks.json`
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,348 +0,0 @@
 | 
				
			|||||||
// PDS Detection and API URL mapping utilities
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { isValidDid, isValidHandle } from './validation';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface NetworkConfig {
 | 
					 | 
				
			||||||
  pdsApi: string;
 | 
					 | 
				
			||||||
  plcApi: string;
 | 
					 | 
				
			||||||
  bskyApi: string;
 | 
					 | 
				
			||||||
  webUrl: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Detect PDS from handle
 | 
					 | 
				
			||||||
export function detectPdsFromHandle(handle: string): string {
 | 
					 | 
				
			||||||
  // Get allowed handles from environment
 | 
					 | 
				
			||||||
  const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
 | 
					 | 
				
			||||||
  let allowedHandles: string[] = [];
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    allowedHandles = JSON.parse(allowedHandlesStr);
 | 
					 | 
				
			||||||
  } catch {
 | 
					 | 
				
			||||||
    allowedHandles = [];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Get configured PDS from environment
 | 
					 | 
				
			||||||
  const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Check if handle is in allowed list
 | 
					 | 
				
			||||||
  if (allowedHandles.includes(handle)) {
 | 
					 | 
				
			||||||
    return configuredPds;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Check if handle ends with .syu.is or .syui.ai
 | 
					 | 
				
			||||||
  if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
 | 
					 | 
				
			||||||
    return 'syu.is';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Check if handle ends with .bsky.social or .bsky.app
 | 
					 | 
				
			||||||
  if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
 | 
					 | 
				
			||||||
    return 'bsky.social';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Default to Bluesky for unknown domains
 | 
					 | 
				
			||||||
  return 'bsky.social';
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Map PDS endpoint to network configuration
 | 
					 | 
				
			||||||
export function getNetworkConfigFromPdsEndpoint(pdsEndpoint: string): NetworkConfig {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const url = new URL(pdsEndpoint);
 | 
					 | 
				
			||||||
    const hostname = url.hostname;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Map based on actual PDS endpoint
 | 
					 | 
				
			||||||
    if (hostname === 'syu.is') {
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: 'https://syu.is',           // PDS API (repo operations)
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.syu.is',       // PLC directory
 | 
					 | 
				
			||||||
        bskyApi: 'https://bsky.syu.is',     // Bluesky API (getProfile, etc.)
 | 
					 | 
				
			||||||
        webUrl: 'https://web.syu.is'        // Web interface
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    } else if (hostname.includes('bsky.network') || hostname === 'bsky.social' || hostname.includes('host.bsky.network')) {
 | 
					 | 
				
			||||||
      // All Bluesky infrastructure (including *.host.bsky.network)
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: pdsEndpoint,                      // Use actual PDS endpoint (e.g., shiitake.us-east.host.bsky.network)
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.directory',          // Standard PLC directory
 | 
					 | 
				
			||||||
        bskyApi: 'https://public.api.bsky.app',   // Bluesky public API (NOT PDS)
 | 
					 | 
				
			||||||
        webUrl: 'https://bsky.app'                // Bluesky web interface
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      // Unknown PDS, assume Bluesky-compatible but use PDS for repo operations
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        pdsApi: pdsEndpoint,                      // Use actual PDS for repo ops
 | 
					 | 
				
			||||||
        plcApi: 'https://plc.directory',          // Default PLC
 | 
					 | 
				
			||||||
        bskyApi: 'https://public.api.bsky.app',   // Default to Bluesky API
 | 
					 | 
				
			||||||
        webUrl: 'https://bsky.app'                // Default web interface
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    // Fallback for invalid URLs
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      pdsApi: 'https://bsky.social',
 | 
					 | 
				
			||||||
      plcApi: 'https://plc.directory',
 | 
					 | 
				
			||||||
      bskyApi: 'https://public.api.bsky.app',
 | 
					 | 
				
			||||||
      webUrl: 'https://bsky.app'
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Legacy function for backwards compatibility
 | 
					 | 
				
			||||||
export function getNetworkConfig(pds: string): NetworkConfig {
 | 
					 | 
				
			||||||
  // This now assumes pds is a hostname
 | 
					 | 
				
			||||||
  return getNetworkConfigFromPdsEndpoint(`https://${pds}`);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Get appropriate API URL for a user based on their handle
 | 
					 | 
				
			||||||
export function getApiUrlForUser(handle: string): string {
 | 
					 | 
				
			||||||
  const pds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
  const config = getNetworkConfig(pds);
 | 
					 | 
				
			||||||
  return config.bskyApi;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Resolve handle/DID to actual PDS endpoint using PLC API first
 | 
					 | 
				
			||||||
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
 | 
					 | 
				
			||||||
  // Validate input
 | 
					 | 
				
			||||||
  if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
 | 
					 | 
				
			||||||
    throw new Error(`Invalid identifier: ${handleOrDid}`);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  let targetDid = handleOrDid;
 | 
					 | 
				
			||||||
  let targetHandle = handleOrDid;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // If handle provided, resolve to DID first using identity.resolveHandle
 | 
					 | 
				
			||||||
  if (!handleOrDid.startsWith('did:')) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Try multiple endpoints for handle resolution
 | 
					 | 
				
			||||||
      const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
 | 
					 | 
				
			||||||
      let resolved = false;
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      for (const endpoint of resolveEndpoints) {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const resolveResponse = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
 | 
					 | 
				
			||||||
          if (resolveResponse.ok) {
 | 
					 | 
				
			||||||
            const resolveData = await resolveResponse.json();
 | 
					 | 
				
			||||||
            targetDid = resolveData.did;
 | 
					 | 
				
			||||||
            resolved = true;
 | 
					 | 
				
			||||||
            break;
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } catch (error) {
 | 
					 | 
				
			||||||
          continue;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      if (!resolved) {
 | 
					 | 
				
			||||||
        throw new Error('Handle resolution failed from all endpoints');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      throw new Error(`Failed to resolve handle ${handleOrDid} to DID: ${error}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // First, try PLC API to get the authoritative DID document
 | 
					 | 
				
			||||||
  const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  for (const plcApi of plcApis) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const plcResponse = await fetch(`${plcApi}/${targetDid}`);
 | 
					 | 
				
			||||||
      if (plcResponse.ok) {
 | 
					 | 
				
			||||||
        const didDocument = await plcResponse.json();
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Find PDS service in DID document
 | 
					 | 
				
			||||||
        const pdsService = didDocument.service?.find((s: any) => 
 | 
					 | 
				
			||||||
          s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (pdsService && pdsService.serviceEndpoint) {
 | 
					 | 
				
			||||||
          return {
 | 
					 | 
				
			||||||
            pds: pdsService.serviceEndpoint,
 | 
					 | 
				
			||||||
            did: targetDid,
 | 
					 | 
				
			||||||
            handle: targetHandle
 | 
					 | 
				
			||||||
          };
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      continue;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
 | 
					 | 
				
			||||||
  const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  for (const pdsEndpoint of pdsEndpoints) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(targetDid)}`);
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      if (response.ok) {
 | 
					 | 
				
			||||||
        const data = await response.json();
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Extract PDS from didDoc.service
 | 
					 | 
				
			||||||
        const services = data.didDoc?.service || [];
 | 
					 | 
				
			||||||
        const pdsService = services.find((s: any) => 
 | 
					 | 
				
			||||||
          s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (pdsService) {
 | 
					 | 
				
			||||||
          return {
 | 
					 | 
				
			||||||
            pds: pdsService.serviceEndpoint,
 | 
					 | 
				
			||||||
            did: data.did || targetDid,
 | 
					 | 
				
			||||||
            handle: data.handle || targetHandle
 | 
					 | 
				
			||||||
          };
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      continue;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  throw new Error(`Failed to resolve PDS for ${handleOrDid} from any endpoint`);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Resolve DID to actual PDS endpoint using com.atproto.repo.describeRepo
 | 
					 | 
				
			||||||
export async function resolvePdsFromDid(did: string): Promise<string> {
 | 
					 | 
				
			||||||
  const resolved = await resolvePdsFromRepo(did);
 | 
					 | 
				
			||||||
  return resolved.pds;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Enhanced resolve handle to DID with proper PDS detection
 | 
					 | 
				
			||||||
export async function resolveHandleToDid(handle: string): Promise<{ did: string; pds: string }> {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // First, try to resolve the handle to DID using multiple methods
 | 
					 | 
				
			||||||
    const apiUrl = getApiUrlForUser(handle);
 | 
					 | 
				
			||||||
    const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (!response.ok) {
 | 
					 | 
				
			||||||
      throw new Error(`Failed to resolve handle: ${response.status}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const data = await response.json();
 | 
					 | 
				
			||||||
    const did = data.did;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Now resolve the actual PDS from the DID
 | 
					 | 
				
			||||||
    const actualPds = await resolvePdsFromDid(did);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      did: did,
 | 
					 | 
				
			||||||
      pds: actualPds
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    // Failed to resolve handle
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Fallback to handle-based detection
 | 
					 | 
				
			||||||
    const fallbackPds = detectPdsFromHandle(handle);
 | 
					 | 
				
			||||||
    throw error;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Get profile using appropriate API for the user with accurate PDS resolution
 | 
					 | 
				
			||||||
export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?: string): Promise<any> {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    let apiUrl: string;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (knownPdsEndpoint) {
 | 
					 | 
				
			||||||
      // If we already know the user's PDS endpoint, use it directly
 | 
					 | 
				
			||||||
      const config = getNetworkConfigFromPdsEndpoint(knownPdsEndpoint);
 | 
					 | 
				
			||||||
      apiUrl = config.bskyApi;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      // Resolve the user's actual PDS using describeRepo
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const resolved = await resolvePdsFromRepo(handleOrDid);
 | 
					 | 
				
			||||||
        const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
 | 
					 | 
				
			||||||
        apiUrl = config.bskyApi;
 | 
					 | 
				
			||||||
      } catch {
 | 
					 | 
				
			||||||
        // Fallback to handle-based detection
 | 
					 | 
				
			||||||
        apiUrl = getApiUrlForUser(handleOrDid);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
 | 
					 | 
				
			||||||
    if (!response.ok) {
 | 
					 | 
				
			||||||
      throw new Error(`Failed to get profile: ${response.status}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return await response.json();
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    // Failed to get profile
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Final fallback: try with default Bluesky API
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
 | 
					 | 
				
			||||||
      if (response.ok) {
 | 
					 | 
				
			||||||
        return await response.json();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch {
 | 
					 | 
				
			||||||
      // Ignore fallback errors
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    throw error;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Test and verify PDS detection methods
 | 
					 | 
				
			||||||
export async function verifyPdsDetection(handleOrDid: string): Promise<void> {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // Method 1: com.atproto.repo.describeRepo (PRIMARY METHOD)
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const resolved = await resolvePdsFromRepo(handleOrDid);
 | 
					 | 
				
			||||||
      const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // describeRepo failed
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Method 2: com.atproto.identity.resolveHandle (for comparison)
 | 
					 | 
				
			||||||
    if (!handleOrDid.startsWith('did:')) {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
 | 
					 | 
				
			||||||
        if (resolveResponse.ok) {
 | 
					 | 
				
			||||||
          const resolveData = await resolveResponse.json();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        // Error resolving handle
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Method 3: PLC Directory lookup (if we have a DID)
 | 
					 | 
				
			||||||
    let targetDid = handleOrDid;
 | 
					 | 
				
			||||||
    if (!handleOrDid.startsWith('did:')) {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const profile = await getProfileForUser(handleOrDid);
 | 
					 | 
				
			||||||
        targetDid = profile.did;
 | 
					 | 
				
			||||||
      } catch {
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const plcResponse = await fetch(`https://plc.directory/${targetDid}`);
 | 
					 | 
				
			||||||
      if (plcResponse.ok) {
 | 
					 | 
				
			||||||
        const didDocument = await plcResponse.json();
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Find PDS service
 | 
					 | 
				
			||||||
        const pdsService = didDocument.service?.find((s: any) => 
 | 
					 | 
				
			||||||
          s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (pdsService) {
 | 
					 | 
				
			||||||
          // Try to detect if this is a known network
 | 
					 | 
				
			||||||
          const pdsUrl = pdsService.serviceEndpoint;
 | 
					 | 
				
			||||||
          const hostname = new URL(pdsUrl).hostname;
 | 
					 | 
				
			||||||
          const detectedNetwork = detectPdsFromHandle(`user.${hostname}`);
 | 
					 | 
				
			||||||
          const networkConfig = getNetworkConfig(hostname);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // Error fetching from PLC directory
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Method 4: Our enhanced resolution
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      if (handleOrDid.startsWith('did:')) {
 | 
					 | 
				
			||||||
        const pdsEndpoint = await resolvePdsFromDid(handleOrDid);
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        const resolved = await resolveHandleToDid(handleOrDid);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      // Enhanced resolution failed
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    // Overall verification failed
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,21 +0,0 @@
 | 
				
			|||||||
// Validation utilities for atproto identifiers
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function isValidDid(did: string): boolean {
 | 
					 | 
				
			||||||
  if (!did || typeof did !== 'string') return false;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Basic DID format: did:method:identifier
 | 
					 | 
				
			||||||
  const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
 | 
					 | 
				
			||||||
  return didRegex.test(did);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function isValidHandle(handle: string): boolean {
 | 
					 | 
				
			||||||
  if (!handle || typeof handle !== 'string') return false;
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  // Basic handle format: subdomain.domain.tld
 | 
					 | 
				
			||||||
  const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
 | 
					 | 
				
			||||||
  return handleRegex.test(handle);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function isValidAtprotoIdentifier(identifier: string): boolean {
 | 
					 | 
				
			||||||
  return isValidDid(identifier) || isValidHandle(identifier);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,21 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "compilerOptions": {
 | 
					 | 
				
			||||||
    "target": "ES2020",
 | 
					 | 
				
			||||||
    "useDefineForClassFields": true,
 | 
					 | 
				
			||||||
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
 | 
					 | 
				
			||||||
    "module": "ESNext",
 | 
					 | 
				
			||||||
    "skipLibCheck": true,
 | 
					 | 
				
			||||||
    "moduleResolution": "bundler",
 | 
					 | 
				
			||||||
    "allowImportingTsExtensions": true,
 | 
					 | 
				
			||||||
    "resolveJsonModule": true,
 | 
					 | 
				
			||||||
    "isolatedModules": true,
 | 
					 | 
				
			||||||
    "noEmit": true,
 | 
					 | 
				
			||||||
    "jsx": "react-jsx",
 | 
					 | 
				
			||||||
    "strict": true,
 | 
					 | 
				
			||||||
    "noUnusedLocals": true,
 | 
					 | 
				
			||||||
    "noUnusedParameters": true,
 | 
					 | 
				
			||||||
    "noFallthroughCasesInSwitch": true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "include": ["src"],
 | 
					 | 
				
			||||||
  "references": [{ "path": "./tsconfig.node.json" }]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,11 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "compilerOptions": {
 | 
					 | 
				
			||||||
    "composite": true,
 | 
					 | 
				
			||||||
    "skipLibCheck": true,
 | 
					 | 
				
			||||||
    "module": "ESNext",
 | 
					 | 
				
			||||||
    "moduleResolution": "bundler",
 | 
					 | 
				
			||||||
    "allowSyntheticDefaultImports": true,
 | 
					 | 
				
			||||||
    "strict": true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "include": ["vite.config.ts"]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,88 +0,0 @@
 | 
				
			|||||||
import { defineConfig, loadEnv } from 'vite'
 | 
					 | 
				
			||||||
import react from '@vitejs/plugin-react'
 | 
					 | 
				
			||||||
import fs from 'fs'
 | 
					 | 
				
			||||||
import path from 'path'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default defineConfig(({ mode }) => {
 | 
					 | 
				
			||||||
  // Load env file based on `mode` in the current working directory.
 | 
					 | 
				
			||||||
  const env = loadEnv(mode, process.cwd(), '')
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    plugins: [
 | 
					 | 
				
			||||||
      react(),
 | 
					 | 
				
			||||||
      // Custom plugin to replace variables in public files during build
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        name: 'replace-env-vars',
 | 
					 | 
				
			||||||
        writeBundle() {
 | 
					 | 
				
			||||||
          const host = env.VITE_APP_HOST || 'https://log.syui.ai'
 | 
					 | 
				
			||||||
          const clientId = env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`
 | 
					 | 
				
			||||||
          const redirectUri = env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Replace variables in client-metadata.json
 | 
					 | 
				
			||||||
          const clientMetadataPath = path.resolve(__dirname, 'dist/client-metadata.json')
 | 
					 | 
				
			||||||
          if (fs.existsSync(clientMetadataPath)) {
 | 
					 | 
				
			||||||
            let content = fs.readFileSync(clientMetadataPath, 'utf-8')
 | 
					 | 
				
			||||||
            content = content.replace(/https:\/\/log\.syui\.ai/g, host)
 | 
					 | 
				
			||||||
            fs.writeFileSync(clientMetadataPath, content)
 | 
					 | 
				
			||||||
            console.log(`Updated client-metadata.json with host: ${host}`)
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      // Generate standalone index.html for testing
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        name: 'generate-standalone-html',
 | 
					 | 
				
			||||||
        writeBundle(options, bundle) {
 | 
					 | 
				
			||||||
          // Find actual generated filenames
 | 
					 | 
				
			||||||
          const jsFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.js'))
 | 
					 | 
				
			||||||
          const cssFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.css'))
 | 
					 | 
				
			||||||
          
 | 
					 | 
				
			||||||
          // Generate minimal index.html with just asset references
 | 
					 | 
				
			||||||
          const indexHtmlPath = path.resolve(__dirname, 'dist/index.html')
 | 
					 | 
				
			||||||
          const indexHtmlContent = `<!-- OAuth Comment System - Load globally for session management -->
 | 
					 | 
				
			||||||
<script type="module" crossorigin src="/${jsFile}"></script>
 | 
					 | 
				
			||||||
<link rel="stylesheet" crossorigin href="/${cssFile}">`
 | 
					 | 
				
			||||||
          fs.writeFileSync(indexHtmlPath, indexHtmlContent)
 | 
					 | 
				
			||||||
          console.log('Generated minimal index.html with asset references')
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    ],
 | 
					 | 
				
			||||||
    build: {
 | 
					 | 
				
			||||||
      // Keep console.log in production for debugging
 | 
					 | 
				
			||||||
      minify: 'esbuild',
 | 
					 | 
				
			||||||
      rollupOptions: {
 | 
					 | 
				
			||||||
        output: {
 | 
					 | 
				
			||||||
          // Hash-based filenames to bust cache
 | 
					 | 
				
			||||||
          entryFileNames: 'assets/comment-atproto-[hash].js',
 | 
					 | 
				
			||||||
          chunkFileNames: 'assets/comment-atproto-[name]-[hash].js',
 | 
					 | 
				
			||||||
          assetFileNames: (assetInfo) => {
 | 
					 | 
				
			||||||
            if (assetInfo.name && assetInfo.name.endsWith('.css')) {
 | 
					 | 
				
			||||||
              return 'assets/comment-atproto-[hash].css';
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return 'assets/[name]-[hash].[ext]';
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    esbuild: {
 | 
					 | 
				
			||||||
      drop: [], // Don't drop console.log
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    server: {
 | 
					 | 
				
			||||||
      port: 5173,
 | 
					 | 
				
			||||||
      host: '127.0.0.1',
 | 
					 | 
				
			||||||
      allowedHosts: ['localhost', '127.0.0.1', 'log.syui.ai'],
 | 
					 | 
				
			||||||
      proxy: {
 | 
					 | 
				
			||||||
        '/api': {
 | 
					 | 
				
			||||||
          target: 'http://127.0.0.1:8000',
 | 
					 | 
				
			||||||
          changeOrigin: true,
 | 
					 | 
				
			||||||
          secure: false,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      // Handle OAuth callback routing
 | 
					 | 
				
			||||||
      historyApiFallback: {
 | 
					 | 
				
			||||||
        rewrites: [
 | 
					 | 
				
			||||||
          { from: /^\/oauth\/callback/, to: '/index.html' }
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user