diff --git a/.gitea/workflows/cloudflare-pages.yml b/.gitea/workflows/cloudflare-pages.yml
new file mode 100644
index 0000000..a057fa2
--- /dev/null
+++ b/.gitea/workflows/cloudflare-pages.yml
@@ -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 }} } } }"
\ No newline at end of file
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..30224a8
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/cloudflare-pages.yml b/.github/workflows/cloudflare-pages.yml
index 4449f6a..bf9116e 100644
--- a/.github/workflows/cloudflare-pages.yml
+++ b/.github/workflows/cloudflare-pages.yml
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
- OAUTH_DIR: oauth_new
+ OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs:
@@ -110,48 +110,49 @@ jobs:
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
- cleanup:
- needs: deploy
- runs-on: ubuntu-latest
- if: success()
-
- steps:
- - name: Wait for deployment to complete
- run: sleep 3
-
- - name: Cleanup old deployments
- run: |
- # Get all deployments
- DEPLOYMENTS=$(curl -s -X GET \
- "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
- -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")
-
- if [ -z "$DEPLOYMENT_IDS" ]; then
- echo "No old deployments to delete"
- exit 0
- fi
-
- # Delete old deployments
- for ID in $DEPLOYMENT_IDS; do
- echo "Deleting deployment: $ID"
- RESPONSE=$(curl -s -X DELETE \
- "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
- -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
- -H "Content-Type: application/json")
-
- SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
- if [ "$SUCCESS" = "true" ]; then
- echo "Successfully deleted deployment: $ID"
- else
- echo "Failed to delete deployment: $ID"
- echo "$RESPONSE" | jq .
- fi
-
- sleep 1 # Rate limiting
- done
-
- echo "Cleanup completed!"
+# 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 }} } } }"
+# # Get all deployments
+# DEPLOYMENTS=$(curl -s -X GET \
+# "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
+# -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")
+#
+# if [ -z "$DEPLOYMENT_IDS" ]; then
+# echo "No old deployments to delete"
+# exit 0
+# fi
+#
+# # Delete old deployments
+# for ID in $DEPLOYMENT_IDS; do
+# echo "Deleting deployment: $ID"
+# RESPONSE=$(curl -s -X DELETE \
+# "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
+# -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
+# -H "Content-Type: application/json")
+#
+# SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
+# if [ "$SUCCESS" = "true" ]; then
+# echo "Successfully deleted deployment: $ID"
+# else
+# echo "Failed to delete deployment: $ID"
+# echo "$RESPONSE" | jq .
+# fi
+#
+# sleep 1 # Rate limiting
+# done
+#
+# echo "Cleanup completed!"
diff --git a/.gitignore b/.gitignore
index 30d935a..4d64041 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,5 +16,6 @@ my-blog/static/index.html
my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config
-oauth-server-example
atproto
+oauth_old
+oauth_example
diff --git a/oauth_new/.env b/oauth/.env
similarity index 100%
rename from oauth_new/.env
rename to oauth/.env
diff --git a/oauth/.env.production b/oauth/.env.production
index 89e9ede..b6d20ee 100644
--- a/oauth/.env.production
+++ b/oauth/.env.production
@@ -1,21 +1,10 @@
-# Production environment variables
-VITE_APP_HOST=https://syui.ai
+VITE_ADMIN=ai.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_REDIRECT_URI=https://syui.ai/oauth/callback
-# Handle-based Configuration (DIDs resolved at runtime)
-VITE_ATPROTO_PDS=syu.is
-VITE_ADMIN_HANDLE=ai.syui.ai
-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とか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
-
+# Production settings - Disable development features
+VITE_ENABLE_TEST_UI=false
+VITE_ENABLE_DEBUG=false
\ No newline at end of file
diff --git a/oauth_new/ASK_AI_INTEGRATION.md b/oauth/ASK_AI_INTEGRATION.md
similarity index 100%
rename from oauth_new/ASK_AI_INTEGRATION.md
rename to oauth/ASK_AI_INTEGRATION.md
diff --git a/oauth_new/AVATAR_SYSTEM.md b/oauth/AVATAR_SYSTEM.md
similarity index 100%
rename from oauth_new/AVATAR_SYSTEM.md
rename to oauth/AVATAR_SYSTEM.md
diff --git a/oauth_new/AVATAR_USAGE_EXAMPLES.md b/oauth/AVATAR_USAGE_EXAMPLES.md
similarity index 100%
rename from oauth_new/AVATAR_USAGE_EXAMPLES.md
rename to oauth/AVATAR_USAGE_EXAMPLES.md
diff --git a/oauth_new/CLOUDFLARE_DEPLOY.yml b/oauth/CLOUDFLARE_DEPLOY.yml
similarity index 100%
rename from oauth_new/CLOUDFLARE_DEPLOY.yml
rename to oauth/CLOUDFLARE_DEPLOY.yml
diff --git a/oauth_new/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml b/oauth/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml
similarity index 100%
rename from oauth_new/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml
rename to oauth/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml
diff --git a/oauth_new/DEPLOYMENT.md b/oauth/DEPLOYMENT.md
similarity index 100%
rename from oauth_new/DEPLOYMENT.md
rename to oauth/DEPLOYMENT.md
diff --git a/oauth_new/DEVELOPMENT.md b/oauth/DEVELOPMENT.md
similarity index 100%
rename from oauth_new/DEVELOPMENT.md
rename to oauth/DEVELOPMENT.md
diff --git a/oauth_new/ENV_SETUP.md b/oauth/ENV_SETUP.md
similarity index 100%
rename from oauth_new/ENV_SETUP.md
rename to oauth/ENV_SETUP.md
diff --git a/oauth_new/IMPLEMENTATION_GUIDE.md b/oauth/IMPLEMENTATION_GUIDE.md
similarity index 100%
rename from oauth_new/IMPLEMENTATION_GUIDE.md
rename to oauth/IMPLEMENTATION_GUIDE.md
diff --git a/oauth_new/IMPROVEMENT_PLAN.md b/oauth/IMPROVEMENT_PLAN.md
similarity index 100%
rename from oauth_new/IMPROVEMENT_PLAN.md
rename to oauth/IMPROVEMENT_PLAN.md
diff --git a/oauth_new/OAUTH_FIX.md b/oauth/OAUTH_FIX.md
similarity index 100%
rename from oauth_new/OAUTH_FIX.md
rename to oauth/OAUTH_FIX.md
diff --git a/oauth_new/PHASE1_QUICK_FIXES.md b/oauth/PHASE1_QUICK_FIXES.md
similarity index 100%
rename from oauth_new/PHASE1_QUICK_FIXES.md
rename to oauth/PHASE1_QUICK_FIXES.md
diff --git a/oauth_new/PROGRESS.md b/oauth/PROGRESS.md
similarity index 100%
rename from oauth_new/PROGRESS.md
rename to oauth/PROGRESS.md
diff --git a/oauth_new/README.md b/oauth/README.md
similarity index 100%
rename from oauth_new/README.md
rename to oauth/README.md
diff --git a/oauth_new/build-minimal.js b/oauth/build-minimal.js
similarity index 100%
rename from oauth_new/build-minimal.js
rename to oauth/build-minimal.js
diff --git a/oauth_new/cleanup-deployments.yml b/oauth/cleanup-deployments.yml
similarity index 100%
rename from oauth_new/cleanup-deployments.yml
rename to oauth/cleanup-deployments.yml
diff --git a/oauth/index.html b/oauth/index.html
index 7652c5a..5664ef4 100644
--- a/oauth/index.html
+++ b/oauth/index.html
@@ -1,20 +1,11 @@
-
-
-
-
- ai.card
-
-
-
-
-
-
+
+
+
+ Comments Test
+
+
+
+
+
\ No newline at end of file
diff --git a/oauth_new/json/ai.syui.ai_chat_comment.json b/oauth/json/ai.syui.ai_chat_comment.json
similarity index 100%
rename from oauth_new/json/ai.syui.ai_chat_comment.json
rename to oauth/json/ai.syui.ai_chat_comment.json
diff --git a/oauth_new/json/ai.syui.ai_chat_lang.json b/oauth/json/ai.syui.ai_chat_lang.json
similarity index 100%
rename from oauth_new/json/ai.syui.ai_chat_lang.json
rename to oauth/json/ai.syui.ai_chat_lang.json
diff --git a/oauth_new/json/ai.syui.ai_log.json b/oauth/json/ai.syui.ai_log.json
similarity index 100%
rename from oauth_new/json/ai.syui.ai_log.json
rename to oauth/json/ai.syui.ai_log.json
diff --git a/oauth_new/json/ai.syui.ai_user.json b/oauth/json/ai.syui.ai_user.json
similarity index 100%
rename from oauth_new/json/ai.syui.ai_user.json
rename to oauth/json/ai.syui.ai_user.json
diff --git a/oauth_new/json/syui.syui.ai_chat.json b/oauth/json/syui.syui.ai_chat.json
similarity index 100%
rename from oauth_new/json/syui.syui.ai_chat.json
rename to oauth/json/syui.syui.ai_chat.json
diff --git a/oauth_new/json/syui.syui.ai_log.json b/oauth/json/syui.syui.ai_log.json
similarity index 100%
rename from oauth_new/json/syui.syui.ai_log.json
rename to oauth/json/syui.syui.ai_log.json
diff --git a/oauth/package.json b/oauth/package.json
index 320fa3c..65400e5 100644
--- a/oauth/package.json
+++ b/oauth/package.json
@@ -1,36 +1,22 @@
{
- "name": "aicard",
- "version": "0.1.1",
- "private": true,
+ "name": "ailog-oauth",
+ "version": "0.2.2",
+ "type": "module",
"scripts": {
- "dev": "vite --mode development",
- "build": "vite build --mode production",
- "build:dev": "vite build --mode development",
- "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"
+ "dev": "vite",
+ "build": "vite build && node build-minimal.js",
+ "preview": "vite preview"
},
"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-dom": "^18.2.0",
- "react-router-dom": "^7.6.1"
+ "@atproto/api": "^0.15.12",
+ "@atproto/oauth-client-browser": "^0.3.19"
},
"devDependencies": {
- "@types/react": "^18.2.45",
- "@types/react-dom": "^18.2.18",
- "@vitejs/plugin-react": "^4.2.1",
- "typescript": "^5.3.3",
- "vite": "^5.0.10",
- "vitest": "^1.1.0",
- "esbuild": "^0.19.10",
- "esbuild-register": "^3.5.0"
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.0.0",
+ "vite": "^5.0.0"
}
}
diff --git a/oauth/public/.well-known/jwks.json b/oauth/public/.well-known/jwks.json
deleted file mode 100644
index d8a3f40..0000000
--- a/oauth/public/.well-known/jwks.json
+++ /dev/null
@@ -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"
- }
- ]
-}
\ No newline at end of file
diff --git a/oauth/public/client-metadata.json b/oauth/public/client-metadata.json
deleted file mode 100644
index 37a408d..0000000
--- a/oauth/public/client-metadata.json
+++ /dev/null
@@ -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
-}
diff --git a/oauth/src/App.css b/oauth/src/App.css
index ec35d9a..4713471 100644
--- a/oauth/src/App.css
+++ b/oauth/src/App.css
@@ -1,360 +1,133 @@
-/* Theme Colors */
+/* Theme Colors - Match ailog style */
:root {
- --theme-color: #FF4500;
- --white: #fff;
- --light-gray: #aaa;
- --dark-gray: #666;
- --background: #fff;
+ --primary: #f40;
+ --primary-hover: #e03000;
+ --danger: #f91880;
+ --danger-hover: #d91a60;
+ --success: #00ba7c;
+ --warning: #ffad1f;
+ --text: #1f2328;
+ --text-secondary: #656d76;
+ --background: #ffffff;
+ --background-secondary: #f6f8fa;
+ --border: #d1d9e0;
+ --hover: rgba(15, 20, 25, 0.1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background: var(--background);
+ color: var(--text);
+ line-height: 1.6;
+ font-size: 16px;
}
.app {
min-height: 100vh;
- background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
- color: var(--dark-gray);
+ background: var(--background);
}
-.app-header {
- text-align: center;
- padding: 40px 20px;
- border-bottom: 1px solid #e9ecef;
- position: relative;
+/* Header */
+.oauth-app-header {
+ background: var(--background);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ width: 100%;
}
-.app-nav {
- display: flex;
- justify-content: center;
- gap: 8px;
- padding: 20px;
- background: rgba(0, 0, 0, 0.02);
- border-bottom: 1px solid #e9ecef;
- margin-bottom: 40px;
-}
-
-.nav-button {
- padding: 12px 20px;
- border: 1px solid #dee2e6;
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.8);
- color: #6c757d;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.3s ease;
- backdrop-filter: blur(10px);
-}
-
-.nav-button:hover {
- background: rgba(102, 126, 234, 0.1);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- color: #495057;
-}
-
-.nav-button.active {
- background: var(--theme-color);
- color: var(--white);
- border: 1px solid var(--theme-color);
- box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
-}
-
-.nav-button.active:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
-}
-
-.app-header h1 {
- font-size: 48px;
- margin: 0;
- background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-
-.app-header p {
- color: #6c757d;
- margin-top: 10px;
-}
-
-.user-info {
- position: absolute;
- top: 20px;
- right: 20px;
+.oauth-header-content {
display: flex;
+ justify-content: flex-start;
align-items: center;
- gap: 15px;
-}
-
-.user-handle {
- color: #495057;
- font-weight: bold;
- background: rgba(102, 126, 234, 0.1);
- padding: 6px 12px;
- border-radius: 20px;
- border: 1px solid #dee2e6;
-}
-
-.login-button,
-.logout-button,
-.backup-button,
-.token-button {
- padding: 8px 16px;
- border: none;
- border-radius: 8px;
- font-size: 12px;
- font-weight: bold;
- cursor: pointer;
- transition: all 0.3s ease;
- margin-left: 8px;
-}
-
-.login-button {
- background: var(--theme-color);
- color: var(--white);
- border: 1px solid var(--theme-color);
-}
-
-.backup-button {
- background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
- color: white;
- border: 1px solid #28a745;
-}
-
-.token-button {
- background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
- color: white;
- border: 1px solid #ffc107;
-}
-
-.logout-button {
- background: rgba(108, 117, 125, 0.1);
- color: #495057;
- border: 1px solid #dee2e6;
-}
-
-.login-button:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
-}
-
-.backup-button:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
-}
-
-.token-button:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
-}
-
-.logout-button:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- background: rgba(108, 117, 125, 0.2);
-}
-
-.loading {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100vh;
- font-size: 24px;
- color: #667eea;
-}
-
-.app-main {
- max-width: 1000px;
- margin: 0 auto;
- padding: 40px 20px;
-}
-
-@media (max-width: 1000px) {
- * {
- max-width: 100% !important;
- box-sizing: border-box !important;
- }
-
- .app .app-main {
- max-width: 100% !important;
- margin: 0 !important;
- padding: 0px !important;
- }
-
- .comment-item {
- padding: 0px !important;
- margin: 0px !important;
- }
-
- .auth-section {
- padding: 0px !important;
- }
-
- .comments-list {
- padding: 0px !important;
- }
-
- .comment-section {
- padding: 30px 0 !important;
- margin: 0px !important;
- }
-
- .comment-content {
- padding: 10px !important;
- word-wrap: break-word !important;
- overflow-wrap: break-word !important;
- white-space: pre-wrap !important;
- }
-
- .comment-header {
- padding: 10px !important;
- }
-
- /* Fix overflow on article pages */
- article.article-content {
- overflow-x: hidden !important;
- }
-
- /* Ensure full width on mobile */
- .app {
- max-width: 100vw !important;
- overflow-x: hidden !important;
- }
-
- /* Fix button overflow */
- button {
- max-width: 100%;
- white-space: normal;
- }
-
- /* Fix comment-meta URI overflow */
- .comment-meta {
- word-break: break-all !important;
- overflow-wrap: break-word !important;
- }
-}
-
-.gacha-section {
- text-align: center;
- margin-bottom: 60px;
-}
-
-.gacha-section h2 {
- font-size: 32px;
- margin-bottom: 30px;
-}
-
-.gacha-buttons {
- display: flex;
- gap: 20px;
- justify-content: center;
- flex-wrap: wrap;
-}
-
-.gacha-button {
- padding: 20px 40px;
- font-size: 18px;
- font-weight: bold;
- border: none;
- border-radius: 12px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- cursor: pointer;
- transition: all 0.3s ease;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
-}
-
-.gacha-button:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
-}
-
-.gacha-button:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.gacha-button-premium {
- background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
- position: relative;
- overflow: hidden;
-}
-
-.gacha-button-premium::before {
- content: '';
- position: absolute;
- top: -50%;
- left: -50%;
- width: 200%;
- height: 200%;
- background: linear-gradient(
- 45deg,
- transparent 30%,
- rgba(255, 255, 255, 0.2) 50%,
- transparent 70%
- );
- animation: shimmer 3s infinite;
-}
-
-.collection-section h2 {
- font-size: 32px;
- text-align: center;
- margin-bottom: 30px;
-}
-
-.card-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
- gap: 30px;
- justify-items: center;
-}
-
-.empty-message {
- text-align: center;
- color: #6c757d;
- font-size: 18px;
- margin-top: 40px;
-}
-
-.error {
- color: #ff4757;
- text-align: center;
- margin-top: 20px;
-}
-
-@keyframes shimmer {
- 0% { transform: translateX(-100%) rotate(45deg); }
- 100% { transform: translateX(100%) rotate(45deg); }
-}
-
-/* Comment System Styles */
-.comment-section {
max-width: 800px;
margin: 0 auto;
- /* padding: 20px; - removed to avoid double padding */
+ padding: 20px 0;
+ width: 100%;
}
-@media (max-width: 768px) {
- .comment-section {
- max-width: 100%;
- margin: 0;
- padding: 0;
- }
+.oauth-app-title {
+ font-size: 20px;
+ font-weight: 800;
+ color: var(--text);
}
+.oauth-header-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ width: 100%;
+}
-.auth-section {
- background: #f8f9fa;
- border: 1px solid #e9ecef;
+/* Buttons */
+.btn {
+ border: none;
border-radius: 8px;
- padding: 20px;
- margin-bottom: 20px;
- text-align: center;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+ padding: 8px 16px;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ padding: 8px 16px;
+}
+
+.btn-danger:hover {
+ background: var(--danger-hover);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 8px 16px;
+}
+
+.btn-outline:hover {
+ background: var(--hover);
+}
+
+.btn-sm {
+ padding: 4px 12px;
+ font-size: 13px;
+}
+
+/* Auth Section */
+.auth-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
.auth-section.search-bar-layout {
display: flex;
align-items: center;
- padding: 10px;
- gap: 10px;
+ padding: 0;
+ gap: 0;
+ width: 100%;
}
.auth-section.search-bar-layout .handle-input {
@@ -362,627 +135,712 @@
margin: 0;
padding: 10px 15px;
font-size: 16px;
- border: 1px solid #dee2e6;
- border-radius: 6px 0 0 6px;
- background: white;
+ border: 1px solid var(--border);
+ border-radius: 8px 0 0 8px;
+ background: var(--background);
outline: none;
transition: border-color 0.2s;
+ width: 100%;
+ text-align: left;
+ color: var(--text);
}
.auth-section.search-bar-layout .handle-input:focus {
- border-color: var(--theme-color);
+ border-color: var(--primary);
}
-.auth-section.search-bar-layout .atproto-button {
+.auth-section.search-bar-layout .auth-button {
+ border-radius: 0 8px 8px 0;
+ border: 1px solid var(--primary);
+ border-left: none;
margin: 0;
- padding: 10px 20px;
- border-radius: 0 6px 6px 0;
- min-width: 50px;
- font-weight: bold;
- height: auto;
+ padding: 10px 15px;
}
-.atproto-button {
- background: var(--theme-color);
- color: var(--white);
+/* Auth Button */
+.auth-button {
+ background: var(--primary);
+ color: white;
border: none;
- padding: 12px 24px;
- border-radius: 6px;
- font-size: 16px;
- font-weight: bold;
+ border-radius: 8px;
+ padding: 8px 16px;
+ font-weight: 700;
cursor: pointer;
- margin-bottom: 15px;
- transition: all 0.3s ease;
+ transition: background 0.2s;
}
-.atproto-button:hover {
- filter: brightness(1.1);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
+.auth-button:hover {
+ background: var(--primary-hover);
}
-.username-input-section {
- margin: 15px 0;
+.auth-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
}
-.handle-input {
- width: 300px;
- max-width: 100%;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 6px;
- font-size: 14px;
- text-align: center;
+/* Main Content */
+.main-content {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px 0;
}
-/* Override for search bar layout */
-.search-bar-layout .handle-input {
- width: auto;
- text-align: left;
+.content-area {
+ background: var(--background);
}
-/* Mobile responsive for search bar */
-@media (max-width: 480px) {
- .auth-section.search-bar-layout {
- flex-direction: column;
- gap: 8px;
- }
-
- .auth-section.search-bar-layout .handle-input {
- width: 100%;
- border-radius: 6px;
- }
-
- .auth-section.search-bar-layout .atproto-button {
- width: 100%;
- border-radius: 6px;
- }
-}
-
-.auth-hint {
- color: #6c757d;
- font-size: 14px;
- margin: 10px 0 0 0;
-}
-
-.user-section {
- background: #e8f5e8;
- border: 1px solid #4caf50;
+/* Card Styles */
+.card {
+ background: var(--background);
+ border: 1px solid var(--border);
border-radius: 8px;
- padding: 20px;
- margin-bottom: 20px;
+ margin: 16px;
+ overflow: hidden;
}
-.user-section .user-info {
- position: static;
- display: block;
- margin-bottom: 20px;
+.card-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 20px;
}
-.user-profile {
- display: flex;
- align-items: center;
- gap: 15px;
- margin-bottom: 15px;
-}
-
-.user-avatar {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- object-fit: cover;
- border: 2px solid #4caf50;
-}
-
-.user-details h3 {
- margin: 0 0 5px 0;
- color: #333;
- font-size: 18px;
-}
-
-.user-section .user-info h3 {
- margin: 0 0 10px 0;
- color: #333;
-}
-
-.user-section .user-handle {
- background: rgba(76, 175, 80, 0.1);
- color: #2e7d32;
- border: 1px solid #4caf50;
-}
-
-.user-section .user-did {
- font-family: monospace;
- font-size: 0.8em;
- color: #666;
- background: #f1f3f4;
- padding: 4px 8px;
- border-radius: 4px;
- margin-top: 5px;
- word-break: break-all;
+.card-content {
+ padding: 16px;
}
+/* Comment Form */
.comment-form {
- background: #fff;
- border: 1px solid #ddd;
- border-radius: 8px;
- padding: 20px;
- margin-bottom: 20px;
+ padding: 16px;
}
.comment-form h3 {
- margin: 0 0 15px 0;
- color: #333;
+ font-size: 20px;
+ font-weight: 800;
+ margin-bottom: 16px;
}
-.comment-form textarea {
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+ display: block;
+ font-weight: 700;
+ margin-bottom: 8px;
+ color: var(--text);
+}
+
+.form-input {
width: 100%;
padding: 12px;
- border: 1px solid #ddd;
- border-radius: 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 16px;
font-family: inherit;
- font-size: 14px;
- resize: vertical;
- box-sizing: border-box;
- min-height: 100px;
+ background: var(--background);
+ color: var(--text);
}
-.comment-form textarea:focus {
- border-color: #1185fe;
+.form-input:focus {
outline: none;
- box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1);
+ border-color: var(--primary);
+}
+
+.form-textarea {
+ min-height: 120px;
+ resize: vertical;
+ font-family: inherit;
}
.form-actions {
display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 10px;
+ justify-content: flex-end;
+ margin-top: 16px;
}
-.char-count {
- color: #666;
- font-size: 0.9em;
+/* Tab Navigation */
+.tab-header {
+ display: flex;
+ background: var(--background);
+ overflow-x: auto;
}
-.post-button {
- background: var(--theme-color);
- color: var(--white);
+.tab-btn {
+ background: none;
border: none;
- padding: 10px 20px;
- border-radius: 6px;
+ padding: 16px 20px;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-secondary);
cursor: pointer;
- font-size: 14px;
- font-weight: bold;
- transition: all 0.3s ease;
+ border-bottom: 2px solid transparent;
+ transition: color 0.2s;
+ white-space: nowrap;
}
-.post-button:hover:not(:disabled) {
- filter: brightness(1.1);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
+.tab-btn:hover {
+ color: var(--text);
+ background: var(--hover);
}
-.post-button:disabled {
- background: #6c757d;
- cursor: not-allowed;
- transform: none;
- box-shadow: none;
+.tab-btn.active {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
}
-.comments-list {
- border-radius: 8px;
- padding: 0px;
+/* Record List */
+.record-item {
+ border-bottom: 1px solid var(--border);
+ padding: 16px;
+ transition: background 0.2s;
+ position: relative;
}
-.comments-header {
+.record-item:hover {
+ background: var(--background-secondary);
+}
+
+.record-header {
display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 12px;
}
-.comments-header h3 {
- margin: 0;
- color: #333;
-}
-
-.comments-controls {
- display: flex;
- gap: 10px;
-}
-
-.comments-toggle-button {
- background: var(--theme-color);
- color: var(--white);
- border: none;
- padding: 8px 16px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 14px;
- font-weight: bold;
- transition: all 0.3s ease;
-}
-
-.comments-toggle-button:hover {
- filter: brightness(1.1);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
-}
-
-.comment-item {
- border: 1px solid #e9ecef;
- border-radius: 6px;
- padding: 15px;
- margin-bottom: 15px;
- background: #fff;
-}
-
-.comment-item:last-child {
- margin-bottom: 0;
-}
-
-.comment-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 10px;
-}
-
-.comment-avatar {
- width: 32px;
- height: 32px;
+.avatar {
+ width: 40px;
+ height: 40px;
border-radius: 50%;
object-fit: cover;
- border: 1px solid #ddd;
+ flex-shrink: 0;
}
-.comment-author-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
+.user-info {
flex: 1;
+ min-width: 0;
}
-.comment-author {
- font-weight: bold;
- color: #333;
- font-size: 0.95em;
+.display-name {
+ font-weight: 700;
+ color: var(--text);
+ font-size: 15px;
}
-.comment-handle {
- color: #666;
- font-size: 0.8em;
+.handle {
+ color: var(--text-secondary);
+ font-size: 15px;
}
-.comment-date {
- color: #666;
- font-size: 0.9em;
- margin-left: auto;
+.handle-link {
+ color: var(--text-secondary);
+ text-decoration: none;
}
-.delete-button {
- background: #dc3545;
- color: white;
- border: none;
- cursor: pointer;
- font-size: 12px;
- font-weight: 500;
- padding: 4px 8px;
- border-radius: 4px;
- transition: all 0.3s ease;
- margin-left: 8px;
+.handle-link:hover {
+ color: var(--primary);
+ text-decoration: underline;
}
-.delete-button:hover {
- background: #c82333;
- transform: scale(1.05);
+.timestamp {
+ color: var(--text-secondary);
+ font-size: 13px;
+ margin-top: 4px;
}
-.comment-content {
+.record-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.record-content {
+ font-size: 15px;
line-height: 1.5;
- color: #333;
- margin-bottom: 10px;
+ color: var(--text);
+ margin-bottom: 12px;
white-space: pre-wrap;
word-wrap: break-word;
}
-.comment-meta {
- padding: 8px;
- background: #f1f3f4;
- border-radius: 4px;
- font-size: 0.8em;
- color: #666;
-}
-
-.comment-meta small {
- font-family: monospace;
-}
-
-.no-comments {
- text-align: center;
- color: #666;
- font-style: italic;
- padding: 40px;
-}
-
-.error {
- background: #f8d7da;
- color: #721c24;
- border: 1px solid #f5c6cb;
- border-radius: 4px;
- padding: 10px;
- margin-top: 10px;
-}
-
-/* Admin Section Styles */
-.admin-section {
- background: #e3f2fd;
- border: 1px solid #2196f3;
- border-radius: 8px;
- padding: 20px;
- margin-top: 20px;
-}
-
-.admin-section h3 {
- margin: 0 0 15px 0;
- color: #1976d2;
- font-size: 16px;
-}
-
-.user-list-form {
- background: #fff;
- border: 1px solid #ddd;
- border-radius: 8px;
- padding: 15px;
-}
-
-.user-list-form textarea {
- width: 100%;
- padding: 12px;
- border: 1px solid #ddd;
- border-radius: 6px;
- font-family: inherit;
- font-size: 14px;
- resize: vertical;
- box-sizing: border-box;
- min-height: 80px;
-}
-
-.user-list-form textarea:focus {
- border-color: #2196f3;
- outline: none;
- box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
-}
-
-.admin-hint {
- color: #666;
- font-size: 0.9em;
- font-style: italic;
-}
-
-/* User List Records Styles */
-.user-list-records {
- margin-top: 20px;
-}
-
-.user-list-records h4 {
- margin: 0 0 15px 0;
- color: #1976d2;
- font-size: 14px;
-}
-
-.no-user-lists {
- text-align: center;
- color: #666;
- font-style: italic;
- padding: 20px;
-}
-
-.user-list-item {
- border: 1px solid #e3f2fd;
- border-radius: 6px;
- padding: 12px;
- margin-bottom: 10px;
- background: #fff;
-}
-
-.user-list-item:last-child {
- margin-bottom: 0;
-}
-
-.user-list-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.user-list-actions {
+.record-meta {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 16px;
+ margin-top: 12px;
}
-.user-list-date {
- color: #666;
- font-size: 0.9em;
- font-weight: 500;
+.record-url {
+ color: var(--primary);
+ text-decoration: none;
+ font-size: 13px;
}
-.user-list-content {
- margin-top: 8px;
-}
-
-.user-handles {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 8px;
-}
-
-.user-handle-tag {
- background: #e3f2fd;
- color: #1976d2;
- padding: 4px 8px;
- border-radius: 12px;
- font-size: 0.85em;
- font-weight: 500;
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.pds-info {
- color: #666;
- font-size: 0.75em;
- font-weight: normal;
-}
-
-.user-list-meta {
- font-size: 0.8em;
- color: #666;
- background: #f8f9fa;
- padding: 6px 8px;
- border-radius: 4px;
- line-height: 1.4;
-}
-
-.user-list-meta small {
- font-family: monospace;
-}
-
-/* JSON Display Styles */
-.json-button {
- background: var(--theme-color);
- color: var(--white);
- border: none;
- padding: 4px 8px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- font-weight: 500;
- transition: all 0.3s ease;
-}
-
-.json-button:hover {
- filter: brightness(1.1);
- transform: scale(1.05);
+.record-url:hover {
+ text-decoration: underline;
}
+/* JSON Display */
.json-display {
margin-top: 12px;
- border: 1px solid #ddd;
- border-radius: 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
overflow: hidden;
}
-.json-display h5 {
- margin: 0;
+.json-header {
+ background: var(--background-secondary);
padding: 8px 12px;
- background: #f1f3f4;
- border-bottom: 1px solid #ddd;
- font-size: 0.9em;
- color: #333;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-secondary);
}
.json-content {
- margin: 0;
- padding: 12px;
background: #f8f9fa;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 0.8em;
+ padding: 12px;
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 12px;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap;
- word-break: break-word;
- color: #333;
- max-height: 400px;
+ max-height: 300px;
overflow-y: auto;
+ color: var(--text);
}
-/* Tab Navigation */
-.tab-navigation {
+/* Ask AI */
+.ask-ai-container {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--background);
+}
+
+.ask-ai-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--background-secondary);
display: flex;
- border-bottom: 2px solid #e1e5e9;
- margin-bottom: 20px;
+ justify-content: space-between;
+ align-items: center;
}
-.tab-button {
- background: none;
- border: none;
- padding: 12px 20px;
- cursor: pointer;
- font-size: 14px;
- font-weight: 500;
- color: #656d76;
- border-bottom: 2px solid transparent;
- transition: all 0.2s;
+.ask-ai-header h3 {
+ font-size: 20px;
+ font-weight: 800;
}
-.tab-button:hover {
- color: var(--theme-color);
- background: #f6f8fa;
+.chat-container {
+ height: 400px;
+ overflow-y: auto;
+ padding: 16px;
}
-.tab-button.active {
- color: var(--theme-color);
- border-bottom-color: var(--theme-color);
- background: #f6f8fa;
+.chat-message {
+ margin-bottom: 16px;
}
+.user-message {
+ margin-left: 40px;
+}
-.chat-actions {
+.ai-message {
+ margin-right: 40px;
+}
+
+.message-header {
display: flex;
align-items: center;
gap: 8px;
-}
-
-.chat-type-button {
- background: var(--theme-color);
- color: var(--white);
- border: none;
- padding: 4px 8px;
- border-radius: 4px;
- cursor: default;
- font-size: 12px;
- font-weight: 500;
- margin-left: 4px;
-}
-
-.chat-type-text {
- font-size: 16px;
- margin-left: 4px;
-}
-
-
-.chat-date {
- color: #656d76;
- font-size: 12px;
-}
-
-.chat-content {
- background: #f6f8fa;
- padding: 12px;
- border-radius: 6px;
- border-left: 4px solid #d1d9e0;
margin-bottom: 8px;
- white-space: pre-wrap;
- line-height: 1.5;
}
-.chat-meta {
- font-size: 11px;
- color: #656d76;
+.message-content {
+ background: var(--background-secondary);
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 15px;
+ line-height: 1.4;
}
-.no-chat {
+.user-message .message-content {
+ background: var(--primary);
+ color: white;
+}
+
+.question-form {
+ padding: 16px;
+ border-top: 1px solid var(--border);
+ background: var(--background);
+}
+
+.input-container {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+}
+
+.question-input {
+ flex: 1;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px 16px;
+ font-size: 16px;
+ resize: none;
+ font-family: inherit;
+ background: var(--background);
+}
+
+.question-input:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.send-btn {
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ width: 36px;
+ height: 36px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+}
+
+.send-btn:hover:not(:disabled) {
+ background: var(--primary-hover);
+}
+
+.send-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Test UI */
+.test-ui {
+ border: 2px solid var(--danger);
+ border-radius: 8px;
+ margin: 16px;
+ background: #fff5f7;
+}
+
+.test-ui h2 {
+ color: var(--danger);
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ margin: 0;
+}
+
+.test-ui .card-content {
+ padding: 16px;
+}
+
+/* Loading Skeleton */
+.loading-skeleton {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.skeleton-line {
+ background: var(--background-secondary);
+ border-radius: 4px;
+ margin-bottom: 8px;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Error States */
+.error-message {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin: 16px 0;
+}
+
+.success-message {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ color: #166534;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin: 16px 0;
+}
+
+/* Auth Notice */
+.auth-notice {
text-align: center;
- padding: 40px 20px;
- color: #656d76;
- font-style: italic;
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin-top: 8px;
}
-.chat-message.comment-style {
- border-left: 4px solid var(--theme-color);
+/* Page Info */
+.page-info {
+ padding: 8px 16px;
+ background: var(--background-secondary);
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: center;
}
+
+.bottom-actions {
+ padding: 20px;
+ text-align: center;
+ margin-top: 20px;
+}
+
+.test-section {
+ margin-top: 20px;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .main-content {
+ max-width: 100%;
+ }
+
+ .content-area {
+ border-left: none;
+ border-right: none;
+ }
+
+ .card {
+ margin: 0;
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ }
+
+ .app-header {
+ padding: 8px 16px;
+ }
+
+ .header-actions {
+ gap: 4px;
+ }
+
+ .btn {
+ padding: 6px 12px;
+ font-size: 14px;
+ }
+
+ .tab-btn {
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ .record-item {
+ padding: 12px 16px;
+ }
+
+ .chat-container {
+ height: 300px;
+ }
+}
+
+/* Avatar Styles */
+.avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+}
+
+.avatar-loading {
+ background: var(--background-secondary);
+ border-radius: 50%;
+ position: relative;
+ overflow: hidden;
+}
+
+.avatar-loading::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ animation: loading-shimmer 1.5s infinite;
+}
+
+@keyframes loading-shimmer {
+ 0% { left: -100%; }
+ 100% { left: 100%; }
+}
+
+.avatar-fallback {
+ background: var(--background-secondary);
+ color: var(--text-secondary);
+ font-weight: 600;
+ border: 1px solid var(--border);
+}
+
+/* Avatar with Card */
+.avatar-container {
+ position: relative;
+ display: inline-block;
+}
+
+.avatar-card {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+ min-width: 200px;
+ margin-top: 8px;
+}
+
+.avatar-card::before {
+ content: '';
+ position: absolute;
+ top: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 8px solid transparent;
+ border-right: 8px solid transparent;
+ border-bottom: 8px solid var(--border);
+}
+
+.avatar-card::after {
+ content: '';
+ position: absolute;
+ top: -7px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 7px solid transparent;
+ border-right: 7px solid transparent;
+ border-bottom: 7px solid var(--background);
+}
+
+.avatar-card-image {
+ display: block;
+ margin: 0 auto 12px;
+}
+
+.avatar-card-info {
+ text-align: center;
+}
+
+.avatar-card-name {
+ font-weight: 700;
+ font-size: 16px;
+ margin-bottom: 4px;
+ color: var(--text);
+}
+
+.avatar-card-handle {
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+}
+
+.avatar-card-handle:hover {
+ color: var(--primary);
+ text-decoration: underline;
+}
+
+/* Avatar List */
+.avatar-list {
+ display: flex;
+ align-items: center;
+}
+
+.avatar-list-item {
+ border: 2px solid var(--background);
+ border-radius: 50%;
+ overflow: hidden;
+}
+
+.avatar-list-more {
+ border: 2px solid var(--background);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+/* Avatar Test Styles */
+.avatar-test-container {
+ margin: 16px;
+}
+
+.test-section {
+ margin-bottom: 32px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.test-section:last-child {
+ border-bottom: none;
+}
+
+.test-section h3 {
+ margin-bottom: 16px;
+ color: var(--text);
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.avatar-examples {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.avatar-example {
+ text-align: center;
+}
+
+.avatar-example h4 {
+ margin-bottom: 8px;
+ font-size: 14px;
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.test-controls {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+/* Utilities */
+.hidden {
+ display: none;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
\ No newline at end of file
diff --git a/oauth_new/src/App.jsx b/oauth/src/App.jsx
similarity index 100%
rename from oauth_new/src/App.jsx
rename to oauth/src/App.jsx
diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx
deleted file mode 100644
index ee70db4..0000000
--- a/oauth/src/App.tsx
+++ /dev/null
@@ -1,1622 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { OAuthCallback } from './components/OAuthCallback';
-import { AIChat } from './components/AIChat';
-import { authService, User } from './services/auth';
-import { atprotoOAuthService } from './services/atproto-oauth';
-import { appConfig, getCollectionNames } from './config/app';
-import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
-import { isValidDid } from './utils/validation';
-import './App.css';
-
-function App() {
- // Handle OAuth callback detection
- if (window.location.search.includes('code=') || window.location.search.includes('state=')) {
- const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`;
- alert(urlInfo);
- }
-
- const [user, setUser] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [comments, setComments] = useState([]);
- const [commentText, setCommentText] = useState('');
- const [isPosting, setIsPosting] = useState(false);
- const [error, setError] = useState(null);
- const [handleInput, setHandleInput] = useState('');
- const [userListInput, setUserListInput] = useState('');
- const [isPostingUserList, setIsPostingUserList] = useState(false);
- const [userListRecords, setUserListRecords] = useState([]);
- const [showJsonFor, setShowJsonFor] = useState(null);
- const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat' | 'lang-en' | 'ai-comment'>('comments');
- const [aiChatHistory, setAiChatHistory] = useState([]);
- const [langEnRecords, setLangEnRecords] = useState([]);
- const [aiCommentRecords, setAiCommentRecords] = useState([]);
- const [aiProfile, setAiProfile] = useState(null);
-
- const [adminDid, setAdminDid] = useState(null);
- const [aiDid, setAiDid] = useState(null);
-
- // ハンドルからDIDを解決する関数
- const resolveHandleToDid = async (handle: string): Promise => {
- try {
- const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(handle));
- return profile?.did || null;
- } catch {
- return null;
- }
- };
-
- useEffect(() => {
- // 管理者とAIのDIDを解決
- const resolveAdminAndAiDids = async () => {
- const [resolvedAdminDid, resolvedAiDid] = await Promise.all([
- resolveHandleToDid(appConfig.adminHandle),
- resolveHandleToDid(appConfig.aiHandle)
- ]);
-
- setAdminDid(resolvedAdminDid || appConfig.adminDid);
- setAiDid(resolvedAiDid || appConfig.aiDid);
- };
-
- resolveAdminAndAiDids();
-
- // Setup Jetstream WebSocket for real-time comments (optional)
- const setupJetstream = () => {
- try {
- const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe');
-
- const collections = getCollectionNames(appConfig.collections.base);
- ws.onopen = () => {
- ws.send(JSON.stringify({
- wantedCollections: [collections.comment]
- }));
- };
-
- ws.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data);
- if (data.collection === collections.comment && data.commit?.operation === 'create') {
- // Optionally reload comments
- // loadAllComments(window.location.href);
- }
- } catch (err) {
- // Ignore parsing errors
- }
- };
-
- ws.onerror = (err) => {
- // Ignore Jetstream errors
- };
-
- return ws;
- } catch (err) {
- return null;
- }
- };
-
- // Jetstream + Cache example (disabled for now)
- // const jetstream = setupJetstream();
-
- // キャッシュからコメント読み込み
- const loadCachedComments = () => {
- const cached = localStorage.getItem('cached_comments_' + window.location.pathname);
- if (cached) {
- const { comments: cachedComments, timestamp } = JSON.parse(cached);
- // 5分以内のキャッシュなら使用
- if (Date.now() - timestamp < 5 * 60 * 1000) {
- setComments(cachedComments);
- return true;
- }
- }
- return false;
- };
-
- // DID解決が完了してからコメントとチャット履歴を読み込む
- const loadDataAfterDidResolution = () => {
- // キャッシュがなければ、ATProtoから取得(認証状態に関係なく)
- if (!loadCachedComments()) {
- loadAllComments(); // URLフィルタリングを無効にして全コメント表示
- }
-
- // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
- loadAiChatHistory();
-
- // Load AI generated content (lang:en and AI comments)
- loadAIGeneratedContent();
- };
-
- // Load data immediately with fallback DIDs (skip DID resolution wait)
- loadDataAfterDidResolution();
-
- // Load AI profile from handle
- const loadAiProfile = async () => {
- try {
- // Use VITE_AI_HANDLE to detect PDS and get profile
- const handle = appConfig.aiHandle;
- if (!handle) {
- throw new Error('No AI handle configured');
- }
-
- // Detect PDS: Use VITE_ATPROTO_PDS if handle matches admin/ai handles
- let pds;
- if (handle === appConfig.adminHandle || handle === appConfig.aiHandle) {
- // Use configured PDS for admin/ai handles
- pds = appConfig.atprotoPds || 'syu.is';
- } else {
- // Use handle-based detection for other handles
- pds = detectPdsFromHandle(handle);
- }
-
- const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
- const apiEndpoint = config.bskyApi;
-
-
- // Get profile from appropriate bsky API
- const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
- if (profileResponse.ok) {
- const profileData = await profileResponse.json();
- setAiProfile({
- did: profileData.did || appConfig.aiDid,
- handle: profileData.handle || handle,
- displayName: profileData.displayName || appConfig.aiDisplayName || 'ai',
- avatar: profileData.avatar || generatePlaceholderAvatar(handle),
- description: profileData.description || appConfig.aiDescription || ''
- });
- } else {
- // Fallback to config values
- setAiProfile({
- did: appConfig.aiDid,
- handle: handle,
- displayName: appConfig.aiDisplayName || 'ai',
- avatar: generatePlaceholderAvatar(handle),
- description: appConfig.aiDescription || ''
- });
- }
- } catch (err) {
- // Failed to load AI profile
- // Fallback to config values
- setAiProfile({
- did: appConfig.aiDid,
- handle: appConfig.aiHandle,
- displayName: appConfig.aiDisplayName || 'ai',
- avatar: generatePlaceholderAvatar(appConfig.aiHandle || 'ai'),
- description: appConfig.aiDescription || ''
- });
- }
- };
- loadAiProfile();
-
- // Handle popstate events for mock OAuth flow
- const handlePopState = () => {
- const urlParams = new URLSearchParams(window.location.search);
- const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
-
- if (isOAuthCallback) {
- // Force re-render to handle OAuth callback
- window.location.reload();
- }
- };
-
- window.addEventListener('popstate', handlePopState);
-
- // Check if this is an OAuth callback
- const urlParams = new URLSearchParams(window.location.search);
- const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
-
- if (isOAuthCallback) {
- return; // Let OAuthCallback component handle this
- }
-
- // Check existing sessions
- const checkAuth = async () => {
- // First check OAuth session using official BrowserOAuthClient
- const oauthResult = await atprotoOAuthService.checkSession();
-
- if (oauthResult) {
- // Ensure handle is not DID
- const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
-
- // Note: appConfig.allowedHandles is used for PDS detection, not access control
-
- // Get user profile including avatar
- const userProfile = await getUserProfile(oauthResult.did, handle);
- setUser(userProfile);
-
- // Load all comments for display (this will be the default view)
- // Temporarily disable URL filtering to see all comments
- loadAllComments();
-
- // Load AI chat history
- loadAiChatHistory();
-
- // Load user list records if admin
- if (userProfile.did === adminDid) {
- loadUserListRecords();
- }
-
- setIsLoading(false);
- return;
- }
-
- // Fallback to legacy auth
- const verifiedUser = await authService.verify();
- if (verifiedUser) {
- // Check if handle is allowed
- if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(verifiedUser.handle)) {
- // Handle not in allowed list
- setError(`Access denied: ${verifiedUser.handle} is not authorized for this application.`);
- setIsLoading(false);
- return;
- }
-
- setUser(verifiedUser);
-
- // Load all comments for display (this will be the default view)
- // Temporarily disable URL filtering to see all comments
- loadAllComments();
-
- // Load user list records if admin
- if (verifiedUser.did === adminDid) {
- loadUserListRecords();
- }
- }
- setIsLoading(false);
-
- // 認証状態に関係なく、コメントを読み込む
- loadAllComments();
- };
-
- checkAuth();
-
- // Load AI generated content (public)
- loadAIGeneratedContent();
-
- return () => {
- window.removeEventListener('popstate', handlePopState);
- };
- }, []);
-
- // DID解決完了時にデータを再読み込み
- useEffect(() => {
- if (adminDid && aiDid) {
- loadAllComments();
- loadAiChatHistory();
- loadAIGeneratedContent();
- }
- }, [adminDid, aiDid]);
-
- const getUserProfile = async (did: string, handle: string): Promise => {
- try {
- const agent = atprotoOAuthService.getAgent();
- if (agent) {
- const profile = await agent.getProfile({ actor: handle });
- return {
- did: did,
- handle: handle,
- avatar: profile.data.avatar,
- displayName: profile.data.displayName || handle
- };
- }
- } catch (error) {
- // Failed to get user profile
- }
-
- // Fallback to basic user info
- return {
- did: did,
- handle: handle,
- avatar: generatePlaceholderAvatar(handle),
- displayName: handle
- };
- };
-
- const generatePlaceholderAvatar = (handle: string): string => {
- const initial = handle ? handle.charAt(0).toUpperCase() : 'U';
- const svg = ``;
- return `data:image/svg+xml;base64,${btoa(svg)}`;
- };
-
- const loadAiChatHistory = async () => {
- try {
- // Load all chat records from users in admin's user list
- const currentAdminDid = adminDid || appConfig.adminDid;
-
- // Use fallback DID if resolution failed
- if (!currentAdminDid) {
- return;
- }
-
- // Resolve admin's actual PDS from their DID
- let adminPdsEndpoint;
- try {
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- adminPdsEndpoint = config.pdsApi;
- } catch {
- // Fallback to configured PDS
- const adminConfig = getNetworkConfig(appConfig.atprotoPds);
- adminPdsEndpoint = adminConfig.pdsApi;
- }
-
- const collections = getCollectionNames(appConfig.collections.base);
-
- const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
-
- if (!userListResponse.ok) {
- setAiChatHistory([]);
- return;
- }
-
- const userListData = await userListResponse.json();
- const userRecords = userListData.records || [];
-
- // Extract unique DIDs from user records (including admin DID for their own chats)
- const allUserDids = [];
- userRecords.forEach(record => {
- if (record.value.users && Array.isArray(record.value.users)) {
- record.value.users.forEach(user => {
- if (user.did) {
- allUserDids.push(user.did);
- }
- });
- }
- });
-
- // Always include admin DID to check admin's own chats
- allUserDids.push(currentAdminDid);
-
- const userDids = [...new Set(allUserDids)];
-
- // Load chat records from all registered users (including admin) using per-user PDS detection
- const allChatRecords = [];
- for (const userDid of userDids) {
- try {
- // Use per-user PDS detection for each user's chat records
- let userPdsEndpoint;
- try {
- // Validate DID format before making API calls
- if (!userDid || !userDid.startsWith('did:')) {
- continue;
- }
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- userPdsEndpoint = config.pdsApi;
- } catch {
- userPdsEndpoint = atprotoApi; // Fallback
- }
-
- const chatResponse = await fetch(`${userPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(userDid)}&collection=${encodeURIComponent(collections.chat)}&limit=100`);
-
- if (chatResponse.ok) {
- const chatData = await chatResponse.json();
- const records = chatData.records || [];
- allChatRecords.push(...records);
- } else if (chatResponse.status === 400) {
- // Skip 400 errors (repo not found, etc)
- continue;
- }
- } catch (err) {
- continue;
- }
- }
-
- // Filter for page-specific content if on a post page
- let filteredRecords = allChatRecords;
- if (appConfig.rkey) {
- // On post page: show only chats for this specific post
- filteredRecords = allChatRecords.filter(record => {
- const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
- return recordRkey === appConfig.rkey;
- });
- } else {
- // On top page: show latest 3 records from all pages
- filteredRecords = allChatRecords.slice(0, 3);
- }
-
- // Filter out old records with invalid AI profile data (temporary fix for migration)
- const validRecords = filteredRecords.filter(record => {
- if (record.value.type === 'answer') {
- // This is an AI answer - check if it has valid AI profile
- return record.value.author?.handle &&
- record.value.author?.handle !== 'ai-assistant' &&
- record.value.author?.displayName !== 'AI Assistant';
- }
- return true; // Keep all questions
- });
-
- // Sort by creation time
- const sortedRecords = validRecords.sort((a, b) =>
- new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
- );
-
- setAiChatHistory(sortedRecords);
- } catch (err) {
- setAiChatHistory([]);
- }
- };
-
- // Load AI generated content from admin DID
- const loadAIGeneratedContent = async () => {
- try {
- const currentAdminDid = adminDid || appConfig.adminDid;
-
- // Use fallback DID if resolution failed
- if (!currentAdminDid) {
- return;
- }
-
- // Resolve admin's actual PDS from their DID
- let atprotoApi;
- try {
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- atprotoApi = config.pdsApi;
- } catch {
- // Fallback to configured PDS
- const adminConfig = getNetworkConfig(appConfig.atprotoPds);
- atprotoApi = adminConfig.pdsApi;
- }
- const collections = getCollectionNames(appConfig.collections.base);
-
- // Load lang:en records
- const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
- if (langResponse.ok) {
- const langData = await langResponse.json();
- const langRecords = langData.records || [];
-
- // Filter by current page rkey if on post page
- const filteredLangRecords = appConfig.rkey
- ? langRecords.filter(record => {
- // Compare rkey only (last part of path)
- const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
- return recordRkey === appConfig.rkey;
- })
- : langRecords.slice(0, 3); // Top page: latest 3
-
- setLangEnRecords(filteredLangRecords);
- }
-
- // Load AI comment records from admin account (not AI account)
- if (!currentAdminDid) {
- console.warn('No Admin DID available, skipping AI comment loading');
- setAiCommentRecords([]);
- return;
- }
-
- const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
- if (commentResponse.ok) {
- const commentData = await commentResponse.json();
- const commentRecords = commentData.records || [];
-
- // Filter by current page rkey if on post page
- const filteredCommentRecords = appConfig.rkey
- ? commentRecords.filter(record => {
- // Compare rkey only (last part of path)
- const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
- return recordRkey === appConfig.rkey;
- })
- : commentRecords.slice(0, 3); // Top page: latest 3
-
- setAiCommentRecords(filteredCommentRecords);
- }
- } catch (err) {
- // Ignore errors
- }
- };
-
- const loadUserComments = async (did: string) => {
- try {
- const agent = atprotoOAuthService.getAgent();
- if (!agent) {
- return;
- }
-
- // Get comments from current user
- const response = await agent.api.com.atproto.repo.listRecords({
- repo: did,
- collection: getCollectionNames(appConfig.collections.base).comment,
- limit: 100,
- });
- const userComments = response.data.records || [];
-
- // Enhance comments with fresh profile information
- const enhancedComments = await Promise.all(
- userComments.map(async (record) => {
- if (record.value.author?.handle) {
- try {
- // Use existing PDS detection logic
- const handle = record.value.author.handle;
- const pds = detectPdsFromHandle(handle);
- const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
- const apiEndpoint = config.bskyApi;
-
- const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
- if (profileResponse.ok) {
- const profileData = await profileResponse.json();
-
- // Determine correct web URL based on avatar source
- let webUrl = config.webUrl; // Default from handle-based detection
- if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
- webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
- }
-
- return {
- ...record,
- value: {
- ...record.value,
- author: {
- ...record.value.author,
- avatar: profileData.avatar,
- displayName: profileData.displayName || handle,
- _pdsEndpoint: `https://${pds}`, // Store PDS info for later use
- _webUrl: webUrl, // Store corrected web URL for profile links
- }
- }
- };
- } else {
- // If profile fetch fails, still add PDS info for links
- return {
- ...record,
- value: {
- ...record.value,
- author: {
- ...record.value.author,
- _pdsEndpoint: `https://${pds}`,
- _webUrl: config.webUrl,
- }
- }
- };
- }
- } catch (err) {
- // Ignore enhancement errors, use existing data
- }
- }
- return record;
- })
- );
-
- setComments(enhancedComments);
- } catch (err) {
- // Ignore load errors
- setComments([]);
- }
- };
-
- // JSONからユーザーリストを取得
- const loadUsersFromRecord = async () => {
- try {
- // 管理者のユーザーリストを取得 using proper PDS detection
- const currentAdminDid = adminDid || appConfig.adminDid;
-
- // Use per-user PDS detection for admin's records
- let adminPdsEndpoint;
- try {
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- adminPdsEndpoint = config.pdsApi;
- } catch {
- adminPdsEndpoint = 'https://bsky.social'; // Fallback
- }
-
- const userCollectionUrl = `${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`;
-
- const response = await fetch(userCollectionUrl);
-
-
- if (!response.ok) {
- return getDefaultUsers();
- }
-
- const data = await response.json();
- const userRecords = data.records || [];
-
- if (userRecords.length === 0) {
- const defaultUsers = getDefaultUsers();
- return defaultUsers;
- }
-
- // レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決
- const allUsers = [];
- for (const record of userRecords) {
- if (record.value.users) {
- // プレースホルダーDIDを実際のDIDに解決
- const resolvedUsers = await Promise.all(
- record.value.users.map(async (user) => {
- if (user.did && user.did.includes('-placeholder')) {
- // Resolving placeholder DID using proper PDS detection
- try {
- const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(user.handle));
- if (profile && profile.did) {
- // Resolved DID
- return {
- ...user,
- did: profile.did
- };
- }
- } catch (err) {
- // Failed to resolve DID
- }
- }
- return user;
- })
- );
- allUsers.push(...resolvedUsers);
- }
- }
-
- return allUsers;
- } catch (err) {
- // Failed to load users from records, using defaults
- return getDefaultUsers();
- }
- };
-
- // ユーザーリスト一覧を読み込み
- const loadUserListRecords = async () => {
- try {
- // Loading user list records using proper PDS detection
- const currentAdminDid = adminDid || appConfig.adminDid;
-
- // Use per-user PDS detection for admin's records
- let adminPdsEndpoint;
- try {
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- adminPdsEndpoint = config.pdsApi;
- } catch {
- adminPdsEndpoint = 'https://bsky.social'; // Fallback
- }
-
- const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`);
-
- if (!response.ok) {
- // Failed to fetch user list records
- setUserListRecords([]);
- return;
- }
-
- const data = await response.json();
- const records = data.records || [];
-
- // 新しい順にソート
- const sortedRecords = records.sort((a, b) =>
- new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
- );
-
- // Loaded user list records
- setUserListRecords(sortedRecords);
- } catch (err) {
- // Failed to load user list records
- setUserListRecords([]);
- }
- };
-
- const getDefaultUsers = () => {
- const currentAdminDid = adminDid || appConfig.adminDid;
- const defaultUsers = [
- // Default admin user
- { did: currentAdminDid, handle: appConfig.adminHandle, pds: 'https://syu.is' },
- ];
-
- // 現在ログインしているユーザーも追加(重複チェック)
- if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) {
- // Detect PDS based on handle
- const userPds = user.handle.endsWith('.syu.is') ? 'https://syu.is' :
- user.handle.endsWith('.syui.ai') ? 'https://syu.is' :
- 'https://bsky.social';
-
- defaultUsers.push({
- did: user.did,
- handle: user.handle,
- pds: userPds
- });
- }
-
- return defaultUsers;
- };
-
- // 新しい関数: 全ユーザーからコメントを収集
- const loadAllComments = async (pageUrl?: string) => {
- try {
-
- // ユーザーリストを動的に取得
- const knownUsers = await loadUsersFromRecord();
-
- const allComments = [];
-
- // 各ユーザーからコメントを収集
- for (const user of knownUsers) {
- try {
-
- // Use per-user PDS detection for repo operations
- let pdsEndpoint;
- try {
- const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(user.did));
- const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
- pdsEndpoint = config.pdsApi;
- } catch {
- // Fallback to user.pds if PDS detection fails
- pdsEndpoint = user.pds;
- }
-
- const collections = getCollectionNames(appConfig.collections.base);
- const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(collections.comment)}&limit=100`);
-
- if (!response.ok) {
- continue;
- }
-
- const data = await response.json();
- const userRecords = data.records || [];
-
- // Flatten comments from new array format
- const userComments = [];
- for (const record of userRecords) {
- if (record.value.comments && Array.isArray(record.value.comments)) {
- // New format: array of comments
- for (const comment of record.value.comments) {
- userComments.push({
- ...record,
- value: comment,
- originalRecord: record // Keep reference to original record
- });
- }
- } else if (record.value.text) {
- // Old format: single comment
- userComments.push(record);
- }
- }
-
-
- // ページpathでフィルタリング(指定された場合)
- const filteredComments = pageUrl && appConfig.rkey
- ? userComments.filter(record => {
- try {
- // Compare rkey only (last part of path)
- const recordRkey = record.value.url ? new URL(record.value.url).pathname.split('/').pop() : '';
- return recordRkey === appConfig.rkey;
- } catch (err) {
- return false;
- }
- })
- : userComments;
-
- allComments.push(...filteredComments);
- } catch (err) {
- }
- }
-
- // 時間順にソート(新しい順)
- const sortedComments = allComments.sort((a, b) =>
- new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
- );
-
- // プロフィール情報で拡張(認証なしでも取得可能)
- const enhancedComments = await Promise.all(
- sortedComments.map(async (record) => {
- if (!record.value.author?.avatar && record.value.author?.handle) {
- try {
- // Use per-user PDS detection for profile fetching
- const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
-
- if (profile) {
- // Determine network config based on profile data
- let webUrl = 'https://bsky.app'; // Default to Bluesky
- if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
- webUrl = 'https://bsky.app';
- } else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
- webUrl = 'https://web.syu.is';
- }
-
- return {
- ...record,
- value: {
- ...record.value,
- author: {
- ...record.value.author,
- avatar: profile.avatar,
- displayName: profile.displayName || record.value.author.handle,
- _webUrl: webUrl, // Store network config for profile URL generation
- }
- }
- };
- }
- } catch (err) {
- // Ignore enhancement errors
- }
- }
- return record;
- })
- );
-
-
- // デバッグ情報を追加
-
- setComments(enhancedComments);
-
- // キャッシュに保存(5分間有効)
- if (pageUrl) {
- const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname;
- const cacheData = {
- comments: enhancedComments,
- timestamp: Date.now()
- };
- localStorage.setItem(cacheKey, JSON.stringify(cacheData));
- }
- } catch (err) {
- setComments([]);
- }
- };
-
-
- const handlePostComment = async () => {
- if (!user || !commentText.trim()) {
- return;
- }
-
- setIsPosting(true);
- setError(null);
-
- try {
- const agent = atprotoOAuthService.getAgent();
- if (!agent) {
- throw new Error('No agent available');
- }
-
- // Create comment record with post-specific rkey
- const now = new Date();
- // Use post rkey if on post page, otherwise use timestamp-based rkey
- const rkey = appConfig.rkey || now.toISOString().replace(/[:.]/g, '-');
-
- const newComment = {
- text: commentText,
- url: window.location.href,
- createdAt: now.toISOString(),
- author: {
- did: user.did,
- handle: user.handle,
- avatar: user.avatar,
- displayName: user.displayName || user.handle,
- },
- };
-
- // Check if record with this rkey already exists
- let existingComments = [];
- try {
- const existingResponse = await agent.api.com.atproto.repo.getRecord({
- repo: user.did,
- collection: getCollectionNames(appConfig.collections.base).comment,
- rkey: rkey,
- });
-
- // Handle both old single comment format and new array format
- if (existingResponse.data.value.comments) {
- // New format: array of comments
- existingComments = existingResponse.data.value.comments;
- } else if (existingResponse.data.value.text) {
- // Old format: single comment, convert to array
- existingComments = [{
- text: existingResponse.data.value.text,
- url: existingResponse.data.value.url,
- createdAt: existingResponse.data.value.createdAt,
- author: existingResponse.data.value.author,
- }];
- }
- } catch (err) {
- // Record doesn't exist yet, that's fine
- }
-
- // Add new comment to the array
- existingComments.push(newComment);
-
- // Create the record with comments array
- const record = {
- $type: getCollectionNames(appConfig.collections.base).comment,
- comments: existingComments,
- url: window.location.href,
- createdAt: now.toISOString(), // Latest update time
- };
-
- // Post to ATProto with rkey
- const response = await agent.api.com.atproto.repo.putRecord({
- repo: user.did,
- collection: getCollectionNames(appConfig.collections.base).comment,
- rkey: rkey,
- record: record,
- });
-
-
- // Clear form and reload all comments
- setCommentText('');
- await loadAllComments(window.location.href);
- } catch (err: any) {
- setError('コメントの投稿に失敗しました: ' + err.message);
- } finally {
- setIsPosting(false);
- }
- };
-
- const handleDeleteComment = async (uri: string) => {
- if (!user) {
- alert('ログインが必要です');
- return;
- }
-
- if (!confirm('このコメントを削除しますか?')) {
- return;
- }
-
- try {
- const agent = atprotoOAuthService.getAgent();
- if (!agent) {
- throw new Error('No agent available');
- }
-
- // Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey
- const uriParts = uri.split('/');
- const rkey = uriParts[uriParts.length - 1];
-
-
- // Delete the record
- await agent.api.com.atproto.repo.deleteRecord({
- repo: user.did,
- collection: getCollectionNames(appConfig.collections.base).comment,
- rkey: rkey,
- });
-
-
- // Reload all comments to reflect the deletion
- await loadAllComments(window.location.href);
-
- } catch (err: any) {
- alert('コメントの削除に失敗しました: ' + err.message);
- }
- };
-
- const handleLogout = async () => {
- // Logout from both services
- await authService.logout();
- atprotoOAuthService.logout();
- setUser(null);
- setComments([]);
- };
-
- // 管理者チェック
- const isAdmin = (user: User | null): boolean => {
- return user?.did === adminDid || user?.did === appConfig.adminDid;
- };
-
- // ユーザーリスト投稿
- const handlePostUserList = async () => {
- if (!user || !userListInput.trim()) {
- return;
- }
-
- if (!isAdmin(user)) {
- alert('管理者のみがユーザーリストを更新できます');
- return;
- }
-
- setIsPostingUserList(true);
- setError(null);
-
- try {
- const agent = atprotoOAuthService.getAgent();
- if (!agent) {
- throw new Error('No agent available');
- }
-
- // ユーザーリストをパース
- const userHandles = userListInput
- .split(',')
- .map(handle => handle.trim())
- .filter(handle => handle.length > 0);
-
- // ユーザーリストを各PDS用に分類し、実際のDIDを解決
- const users = await Promise.all(userHandles.map(async (handle) => {
- const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social';
-
- // 実際のDIDを解決
- let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック
-
- try {
- // Public APIでプロフィールを取得してDIDを解決
- const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
- if (profileResponse.ok) {
- const profileData = await profileResponse.json();
- if (profileData.did) {
- resolvedDid = profileData.did;
- }
- }
- } catch (err) {
- }
-
- return {
- handle: handle,
- pds: pds,
- did: resolvedDid
- };
- }));
-
- // Create user list record with ISO datetime rkey
- const now = new Date();
- const rkey = now.toISOString().replace(/[:.]/g, '-');
-
- const record = {
- $type: getCollectionNames(appConfig.collections.base).user,
- users: users,
- createdAt: now.toISOString(),
- updatedBy: {
- did: user.did,
- handle: user.handle,
- },
- };
-
- // Post to ATProto with rkey
- const response = await agent.api.com.atproto.repo.putRecord({
- repo: user.did,
- collection: getCollectionNames(appConfig.collections.base).user,
- rkey: rkey,
- record: record,
- });
-
-
- // Clear form and reload user list records
- setUserListInput('');
- loadUserListRecords();
- alert('ユーザーリストが更新されました');
- } catch (err: any) {
- setError('ユーザーリストの投稿に失敗しました: ' + err.message);
- } finally {
- setIsPostingUserList(false);
- }
- };
-
- // ユーザーリスト削除
- const handleDeleteUserList = async (uri: string) => {
- if (!user || !isAdmin(user)) {
- alert('管理者のみがユーザーリストを削除できます');
- return;
- }
-
- if (!confirm('このユーザーリストを削除しますか?')) {
- return;
- }
-
- try {
- const agent = atprotoOAuthService.getAgent();
- if (!agent) {
- throw new Error('No agent available');
- }
-
- // Extract rkey from URI
- const uriParts = uri.split('/');
- const rkey = uriParts[uriParts.length - 1];
-
-
- // Delete the record
- await agent.api.com.atproto.repo.deleteRecord({
- repo: user.did,
- collection: getCollectionNames(appConfig.collections.base).user,
- rkey: rkey,
- });
-
- loadUserListRecords();
- alert('ユーザーリストが削除されました');
-
- } catch (err: any) {
- alert('ユーザーリストの削除に失敗しました: ' + err.message);
- }
- };
-
- // JSON表示のトグル
- const toggleJsonDisplay = (uri: string) => {
- if (showJsonFor === uri) {
- setShowJsonFor(null);
- } else {
- setShowJsonFor(uri);
- }
- };
-
- // OAuth実行関数
- const executeOAuth = async () => {
- if (!handleInput.trim()) {
- alert('Please enter your Bluesky handle first');
- return;
- }
- try {
- await atprotoOAuthService.initiateOAuthFlow(handleInput);
- } catch (err) {
- alert('認証の開始に失敗しました。再度お試しください。');
- }
- };
-
- // ユーザーハンドルからプロフィールURLを生成
- const generateProfileUrl = (author: any): string => {
- // Check if this is admin/AI handle that should use configured PDS
- if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
- const config = getNetworkConfig(appConfig.atprotoPds);
- return `${config.webUrl}/profile/${author.did}`;
- }
-
- // For ai.syu.is handle, also use configured PDS
- if (author.handle === 'ai.syu.is') {
- const config = getNetworkConfig(appConfig.atprotoPds);
- return `${config.webUrl}/profile/${author.did}`;
- }
-
- // For other users, detect network based on avatar URL or stored network info
- if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
- // User has Bluesky avatar, use Bluesky web interface
- return `https://bsky.app/profile/${author.did}`;
- }
-
- // Check if we have stored network config from profile fetching
- if (author._webUrl) {
- return `${author._webUrl}/profile/${author.did}`;
- }
-
- // Fallback: Get PDS from handle for other users
- const pds = detectPdsFromHandle(author.handle);
- const config = getNetworkConfig(pds);
-
- // Use DID for profile URL
- return `${config.webUrl}/profile/${author.did}`;
- };
-
- // Rkey-based comment filtering
- // If on post page (/posts/xxx.html), only show comments with rkey=xxx
- const shouldShowComment = (record: any): boolean => {
- // If not on a post page, show all comments
- if (!appConfig.rkey) {
- return true;
- }
-
- // Extract rkey from comment URI: at://did:plc:xxx/collection/rkey
- // Handle both original records and flattened records from new array format
- const uri = record.uri || record.originalRecord?.uri;
- if (!uri) {
- return false;
- }
-
- const uriParts = uri.split('/');
- const commentRkey = uriParts[uriParts.length - 1];
-
- // Show comment only if rkey matches current post
- return commentRkey === appConfig.rkey;
- };
-
- // OAuth callback is now handled by React Router in main.tsx
-
- // Unified rendering function for AI content
- const renderAIContent = (record: any, index: number, className: string) => {
- // Handle both new format (record.value.$type) and old format compatibility
- const value = record.value;
- const isNewFormat = value.$type && value.post && value.author;
-
- // Extract content based on format
- const contentText = isNewFormat ? value.text : (value.content || value.body || '');
- // Use the author from the record if available, otherwise fall back to AI profile
- const authorInfo = value.author || aiProfile;
-
- const postInfo = isNewFormat ? value.post : null;
- const contentType = value.type || 'unknown';
- const createdAt = value.createdAt || value.generated_at || '';
-
- return (
-
-
-

-
-
- {new Date(createdAt).toLocaleString()}
-
-
-
-
-
-
-
-
- {/* JSON Display */}
- {showJsonFor === record.uri && (
-
-
JSON Record:
-
- {JSON.stringify(record, null, 2)}
-
-
- )}
-
-
- {contentText?.split('\n').map((line: string, index: number) => (
-
- {line}
- {index < contentText.split('\n').length - 1 &&
}
-
- ))}
-
-
- );
- };
-
- const getTypeLabel = (collectionType: string, contentType: string) => {
- if (!collectionType) return contentType;
-
- const collections = getCollectionNames(appConfig.collections.base);
-
- if (collectionType === collections.chat) {
- return contentType === 'question' ? '質問' : '回答';
- }
- if (collectionType === collections.chatLang) {
- return `翻訳: ${contentType.toUpperCase()}`;
- }
- if (collectionType === collections.chatComment) {
- return `AI ${contentType}`;
- }
- return contentType;
- };
-
- return (
-
-
-
-
- {/* Authentication Section */}
- {!user ? (
-
- setHandleInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- executeOAuth();
- }
- }}
- />
-
-
- ) : (
-
-
-
-

-
-
{user.displayName || user.handle}
-
@{user.handle}
-
{user.did}
-
-
-
-
-
- {/* Admin Section - User Management */}
- {isAdmin(user) && (
-
-
管理者機能 - ユーザーリスト管理
-
- {/* User List Form */}
-
-
- {/* User List Records */}
-
-
ユーザーリスト一覧 ({userListRecords.length}件)
- {userListRecords.length === 0 ? (
-
ユーザーリストが見つかりません
- ) : (
- userListRecords.map((record, index) => (
-
-
-
- {new Date(record.value.createdAt).toLocaleString()}
-
-
-
-
-
-
-
-
- {record.value.users && record.value.users.map((user, userIndex) => (
-
- {user.handle}
- ({new URL(user.pds).hostname})
-
- ))}
-
-
- URI: {record.uri}
-
- Updated by: {record.value.updatedBy?.handle || 'unknown'}
-
-
- {/* JSON Display */}
- {showJsonFor === record.uri && (
-
-
JSON Record:
-
- {JSON.stringify(record, null, 2)}
-
-
- )}
-
-
- ))
- )}
-
-
- )}
-
-
- )}
-
- {/* Tab Navigation */}
-
-
-
-
-
-
-
- {/* Comments List */}
- {activeTab === 'comments' && (
-
- {comments.filter(shouldShowComment).length === 0 ? (
-
- {appConfig.rkey ? `No comments for this post yet` : `No comments yet`}
-
- ) : (
- comments.filter(shouldShowComment).map((record, index) => (
-
-
-

-
-
- {new Date(record.value.createdAt).toLocaleString()}
-
-
-
- {/* Show delete button only for current user's comments */}
- {user && record.value.author?.did === user.did && (
-
- )}
-
-
-
-
-
- {/* JSON Display */}
- {showJsonFor === record.uri && (
-
-
JSON Record:
-
- {JSON.stringify(record, null, 2)}
-
-
- )}
-
-
- {record.value.text?.split('\n').map((line: string, index: number) => (
-
- {line}
- {index < record.value.text.split('\n').length - 1 &&
}
-
- ))}
-
-
- ))
- )}
-
- )}
-
- {/* AI Chat History List */}
- {activeTab === 'ai-chat' && (
-
- {aiChatHistory.length === 0 ? (
-
No AI conversations yet. Start chatting with Ask AI!
- ) : (
- aiChatHistory.map((record, index) =>
- renderAIContent(record, index, 'comment-item')
- )
- )}
-
- )}
-
- {/* Lang: EN List */}
- {activeTab === 'lang-en' && (
-
- {langEnRecords.length === 0 ? (
-
No EN translations yet
- ) : (
- langEnRecords.map((record, index) =>
- renderAIContent(record, index, 'lang-item')
- )
- )}
-
- )}
-
- {/* AI Comment List */}
- {activeTab === 'ai-comment' && (
-
- {aiCommentRecords.length === 0 ? (
-
No AI comments yet
- ) : (
- aiCommentRecords.map((record, index) =>
- renderAIContent(record, index, 'comment-item')
- )
- )}
-
- )}
-
- {/* Comment Form - Only show on post pages when Comments tab is active */}
- {user && appConfig.rkey && activeTab === 'comments' && (
-
-
- )}
-
-
-
-
- {/* AI Chat Component - handles all AI functionality */}
-
-
- );
-}
-
-export default App;
diff --git a/oauth_new/src/api/atproto.js b/oauth/src/api/atproto.js
similarity index 100%
rename from oauth_new/src/api/atproto.js
rename to oauth/src/api/atproto.js
diff --git a/oauth/src/components/AIChat-access.tsx b/oauth/src/components/AIChat-access.tsx
deleted file mode 100644
index f808354..0000000
--- a/oauth/src/components/AIChat-access.tsx
+++ /dev/null
@@ -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,
- }
- }),
-});
\ No newline at end of file
diff --git a/oauth/src/components/AIChat.tsx b/oauth/src/components/AIChat.tsx
deleted file mode 100644
index 72b8bf3..0000000
--- a/oauth/src/components/AIChat.tsx
+++ /dev/null
@@ -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 = ({ user, isEnabled }) => {
- const [chatHistory, setChatHistory] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [aiProfile, setAiProfile] = useState(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;
-};
\ No newline at end of file
diff --git a/oauth/src/components/AIProfile.tsx b/oauth/src/components/AIProfile.tsx
deleted file mode 100644
index 4ca05f2..0000000
--- a/oauth/src/components/AIProfile.tsx
+++ /dev/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 = ({ aiDid }) => {
- const [profile, setProfile] = useState(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 Loading AI profile...
;
- }
-
- if (!profile) {
- return null;
- }
-
- return (
-
-
- {profile.avatar ? (
-

- ) : (
-
🤖
- )}
-
-
-
{profile.displayName || profile.handle}
-
@{profile.handle}
- {profile.description && (
-
{profile.description}
- )}
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth_new/src/components/AskAI.jsx b/oauth/src/components/AskAI.jsx
similarity index 100%
rename from oauth_new/src/components/AskAI.jsx
rename to oauth/src/components/AskAI.jsx
diff --git a/oauth_new/src/components/AuthButton.jsx b/oauth/src/components/AuthButton.jsx
similarity index 100%
rename from oauth_new/src/components/AuthButton.jsx
rename to oauth/src/components/AuthButton.jsx
diff --git a/oauth_new/src/components/Avatar.jsx b/oauth/src/components/Avatar.jsx
similarity index 100%
rename from oauth_new/src/components/Avatar.jsx
rename to oauth/src/components/Avatar.jsx
diff --git a/oauth_new/src/components/AvatarImage.jsx b/oauth/src/components/AvatarImage.jsx
similarity index 100%
rename from oauth_new/src/components/AvatarImage.jsx
rename to oauth/src/components/AvatarImage.jsx
diff --git a/oauth_new/src/components/AvatarTest.jsx b/oauth/src/components/AvatarTest.jsx
similarity index 100%
rename from oauth_new/src/components/AvatarTest.jsx
rename to oauth/src/components/AvatarTest.jsx
diff --git a/oauth_new/src/components/AvatarTestPanel.jsx b/oauth/src/components/AvatarTestPanel.jsx
similarity index 100%
rename from oauth_new/src/components/AvatarTestPanel.jsx
rename to oauth/src/components/AvatarTestPanel.jsx
diff --git a/oauth/src/components/Card.tsx b/oauth/src/components/Card.tsx
deleted file mode 100644
index 4d6dc2e..0000000
--- a/oauth/src/components/Card.tsx
+++ /dev/null
@@ -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 = {
- 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 = ({ 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 (
-
-
-

{
- (e.target as HTMLImageElement).style.display = 'none';
- }}
- />
-
-
- );
- }
-
- // Detailed view - all information
- return (
-
-
-
- #{card.id}
- CP: {card.cp}
-
-
-
-

{
- (e.target as HTMLImageElement).style.display = 'none';
- }}
- />
-
-
-
-
{cardInfo.name}
- {card.is_unique && (
-
UNIQUE
- )}
-
-
- {card.skill && (
-
- )}
-
-
- {card.status.toUpperCase()}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth/src/components/CardBox.tsx b/oauth/src/components/CardBox.tsx
deleted file mode 100644
index 673e047..0000000
--- a/oauth/src/components/CardBox.tsx
+++ /dev/null
@@ -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 = ({ userDid }) => {
- const [boxData, setBoxData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(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 (
-
- );
- }
-
- if (error) {
- return (
-
-
エラー: {error}
-
-
- );
- }
-
- const records = boxData?.records || [];
- const selfRecord = records.find((record: any) => record.uri.includes('/self'));
- const cards = selfRecord?.value?.cards || [];
-
- return (
-
-
-
📦 atproto カードボックス
-
-
-
- {cards.length > 0 && (
-
- )}
-
-
-
-
-
- 📍 URI:
- at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self
-
-
-
- {showJson && (
-
-
Raw JSON データ:
-
- {JSON.stringify(boxData, null, 2)}
-
-
- )}
-
-
-
- 総カード数: {cards.length}枚
- {selfRecord?.value?.updated_at && (
- <>
-
- 最終更新: {new Date(selfRecord.value.updated_at).toLocaleString()}
- >
- )}
-
-
-
- {cards.length > 0 ? (
- <>
-
- {cards.map((card: any, index: number) => (
-
-
-
- ID: {card.id} | CP: {card.cp}
-
-
- ))}
-
- >
- ) : (
-
-
カードボックスにカードがありません
-
カードを引いてからバックアップボタンを押してください
-
- )}
-
- );
-};
\ No newline at end of file
diff --git a/oauth/src/components/CardList.tsx b/oauth/src/components/CardList.tsx
deleted file mode 100644
index f708cb8..0000000
--- a/oauth/src/components/CardList.tsx
+++ /dev/null
@@ -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([]);
- const [error, setError] = useState(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 (
-
- );
- }
-
- if (error) {
- return (
-
-
Error: {error}
-
-
- );
- }
-
- // 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 (
-
-
-
-
- {displayCards.map(({ card, data, patternName }) => (
-
-
-
-
ID: {data.id}
-
Name: {data.name}
-
日本語名: {data.ja_name}
-
レアリティ: {card.status}
-
CP: {card.cp}
-
CP範囲: {data.base_cp_min}-{data.base_cp_max}
- {data.description && (
-
{data.description}
- )}
-
-
- ))}
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth/src/components/CollectionAnalysis.tsx b/oauth/src/components/CollectionAnalysis.tsx
deleted file mode 100644
index 327d9b7..0000000
--- a/oauth/src/components/CollectionAnalysis.tsx
+++ /dev/null
@@ -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;
- collection_score: number;
- recommendations: string[];
-}
-
-interface CollectionAnalysisProps {
- userDid: string;
-}
-
-export const CollectionAnalysis: React.FC = ({ userDid }) => {
- const [analysis, setAnalysis] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(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 (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- if (!analysis) {
- return (
-
-
-
分析データがありません
-
-
-
- );
- }
-
- return (
-
-
🧠 AI コレクション分析
-
-
-
-
{analysis.total_cards}
-
総カード数
-
-
-
{analysis.unique_cards}
-
ユニークカード
-
-
-
{analysis.collection_score}
-
コレクションスコア
-
-
-
-
-
レアリティ分布
-
- {Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
-
- ))}
-
-
-
- {analysis.recommendations && analysis.recommendations.length > 0 && (
-
-
🎯 AI推奨
-
- {analysis.recommendations.map((rec, index) => (
- - {rec}
- ))}
-
-
- )}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth_new/src/components/CommentForm.jsx b/oauth/src/components/CommentForm.jsx
similarity index 100%
rename from oauth_new/src/components/CommentForm.jsx
rename to oauth/src/components/CommentForm.jsx
diff --git a/oauth/src/components/GachaAnimation.tsx b/oauth/src/components/GachaAnimation.tsx
deleted file mode 100644
index 686906b..0000000
--- a/oauth/src/components/GachaAnimation.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
-
- {phase === 'opening' && (
-
-
-
- )}
-
- {phase === 'revealing' && (
-
-
-
- )}
-
- {phase === 'complete' && showCard && (
-
-
-
-
-
クリックして閉じる
-
-
- )}
-
-
- {animationType === 'unique' && (
-
- )}
-
- );
-};
\ No newline at end of file
diff --git a/oauth/src/components/GachaStats.tsx b/oauth/src/components/GachaStats.tsx
deleted file mode 100644
index 6665c77..0000000
--- a/oauth/src/components/GachaStats.tsx
+++ /dev/null
@@ -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;
- success_rates: Record;
- recent_activity: Array<{
- timestamp: string;
- user_did: string;
- card_name: string;
- rarity: string;
- }>;
-}
-
-export const GachaStats: React.FC = () => {
- const [stats, setStats] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(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 (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- if (!stats) {
- return (
-
-
-
統計データがありません
-
-
-
- );
- }
-
- return (
-
-
📊 ガチャ統計
-
-
-
-
{stats.total_draws}
-
総ガチャ実行数
-
-
-
-
-
レアリティ別出現数
-
- {Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
-
-
{count}
-
{rarity}
- {stats.success_rates[rarity] && (
-
- {(stats.success_rates[rarity] * 100).toFixed(1)}%
-
- )}
-
- ))}
-
-
-
- {stats.recent_activity && stats.recent_activity.length > 0 && (
-
-
最近の活動
-
- {stats.recent_activity.slice(0, 5).map((activity, index) => (
-
-
- {new Date(activity.timestamp).toLocaleString()}
-
-
-
- {activity.rarity}
-
- {activity.card_name}
-
-
- ))}
-
-
- )}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth_new/src/components/LoadingSkeleton.jsx b/oauth/src/components/LoadingSkeleton.jsx
similarity index 100%
rename from oauth_new/src/components/LoadingSkeleton.jsx
rename to oauth/src/components/LoadingSkeleton.jsx
diff --git a/oauth/src/components/Login.tsx b/oauth/src/components/Login.tsx
deleted file mode 100644
index f153fb9..0000000
--- a/oauth/src/components/Login.tsx
+++ /dev/null
@@ -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 = ({ 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(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 (
-
- e.stopPropagation()}
- >
- atprotoログイン
-
-
-
-
-
-
- {loginMode === 'oauth' ? (
-
-
-
🔐 OAuth 2.1 認証
-
- より安全で標準準拠の認証方式です。
- ブラウザが一時的にatproto認証サーバーにリダイレクトされます。
-
- {(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
-
- 🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)
-
- )}
-
-
-
-
- setIdentifier(e.target.value)}
- placeholder="your.handle.bsky.social"
- required
- disabled={isLoading}
- />
-
-
- {error && (
-
{error}
- )}
-
-
-
-
-
-
- ) : (
-
- )}
-
-
-
- ai.logはatprotoアカウントを使用します。
- コメントはあなたのPDSに保存されます。
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth_new/src/components/OAuthCallback.jsx b/oauth/src/components/OAuthCallback.jsx
similarity index 100%
rename from oauth_new/src/components/OAuthCallback.jsx
rename to oauth/src/components/OAuthCallback.jsx
diff --git a/oauth/src/components/OAuthCallback.tsx b/oauth/src/components/OAuthCallback.tsx
deleted file mode 100644
index aeb8769..0000000
--- a/oauth/src/components/OAuthCallback.tsx
+++ /dev/null
@@ -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 = ({ onSuccess, onError }) => {
-
- const [isProcessing, setIsProcessing] = useState(true);
- const [needsHandle, setNeedsHandle] = useState(false);
- const [handle, setHandle] = useState('');
- const [tempSession, setTempSession] = useState(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 (
-
-
-
Blueskyハンドルを入力してください
-
OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。
-
- 入力中: {handle || '(未入力)'} | 文字数: {handle.length}
-
-
-
-
- );
- }
-
- if (isProcessing) {
- return (
-
- );
- }
-
- 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);
\ No newline at end of file
diff --git a/oauth/src/components/OAuthCallbackPage.tsx b/oauth/src/components/OAuthCallbackPage.tsx
deleted file mode 100644
index 6c30872..0000000
--- a/oauth/src/components/OAuthCallbackPage.tsx
+++ /dev/null
@@ -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 (
-
-
Processing OAuth callback...
-
-
- );
-};
\ No newline at end of file
diff --git a/oauth_new/src/components/RecordList.jsx b/oauth/src/components/RecordList.jsx
similarity index 100%
rename from oauth_new/src/components/RecordList.jsx
rename to oauth/src/components/RecordList.jsx
diff --git a/oauth_new/src/components/RecordTabs.jsx b/oauth/src/components/RecordTabs.jsx
similarity index 100%
rename from oauth_new/src/components/RecordTabs.jsx
rename to oauth/src/components/RecordTabs.jsx
diff --git a/oauth_new/src/components/TestUI.jsx b/oauth/src/components/TestUI.jsx
similarity index 100%
rename from oauth_new/src/components/TestUI.jsx
rename to oauth/src/components/TestUI.jsx
diff --git a/oauth_new/src/components/UserLookup.jsx b/oauth/src/components/UserLookup.jsx
similarity index 100%
rename from oauth_new/src/components/UserLookup.jsx
rename to oauth/src/components/UserLookup.jsx
diff --git a/oauth/src/config/app.ts b/oauth/src/config/app.ts
deleted file mode 100644
index b73ff51..0000000
--- a/oauth/src/config/app.ts
+++ /dev/null
@@ -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();
\ No newline at end of file
diff --git a/oauth_new/src/config/env.js b/oauth/src/config/env.js
similarity index 100%
rename from oauth_new/src/config/env.js
rename to oauth/src/config/env.js
diff --git a/oauth_new/src/hooks/useAdminData.js b/oauth/src/hooks/useAdminData.js
similarity index 100%
rename from oauth_new/src/hooks/useAdminData.js
rename to oauth/src/hooks/useAdminData.js
diff --git a/oauth_new/src/hooks/useAskAI.js b/oauth/src/hooks/useAskAI.js
similarity index 100%
rename from oauth_new/src/hooks/useAskAI.js
rename to oauth/src/hooks/useAskAI.js
diff --git a/oauth_new/src/hooks/useAuth.js b/oauth/src/hooks/useAuth.js
similarity index 100%
rename from oauth_new/src/hooks/useAuth.js
rename to oauth/src/hooks/useAuth.js
diff --git a/oauth_new/src/hooks/usePageContext.js b/oauth/src/hooks/usePageContext.js
similarity index 100%
rename from oauth_new/src/hooks/usePageContext.js
rename to oauth/src/hooks/usePageContext.js
diff --git a/oauth_new/src/hooks/useUserData.js b/oauth/src/hooks/useUserData.js
similarity index 100%
rename from oauth_new/src/hooks/useUserData.js
rename to oauth/src/hooks/useUserData.js
diff --git a/oauth_new/src/main.jsx b/oauth/src/main.jsx
similarity index 100%
rename from oauth_new/src/main.jsx
rename to oauth/src/main.jsx
diff --git a/oauth/src/main.tsx b/oauth/src/main.tsx
deleted file mode 100644
index af796dd..0000000
--- a/oauth/src/main.tsx
+++ /dev/null
@@ -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(
-
-
-
- } />
- } />
- } />
-
-
- ,
- );
-});
\ No newline at end of file
diff --git a/oauth/src/services/api.ts b/oauth/src/services/api.ts
deleted file mode 100644
index 3c2d48d..0000000
--- a/oauth/src/services/api.ts
+++ /dev/null
@@ -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 => {
- 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 => {
- if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
- return false;
- }
-
- try {
- await aiGptApi.get('/health');
- return true;
- } catch (error) {
- return false;
- }
- },
-};
\ No newline at end of file
diff --git a/oauth/src/services/atproto-oauth.ts b/oauth/src/services/atproto-oauth.ts
deleted file mode 100644
index 615fa69..0000000
--- a/oauth/src/services/atproto-oauth.ts
+++ /dev/null
@@ -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 | null = null;
-
- constructor() {
- // Don't initialize immediately, wait for first use
- }
-
- private async initialize(): Promise {
- // Prevent multiple initializations
- if (this.initializePromise) {
- return this.initializePromise;
- }
-
- this.initializePromise = this._doInitialize();
- return this.initializePromise;
- }
-
- private async _doInitialize(): Promise {
- 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 {
- 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 {
- 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 {
- // 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 {
- // 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 {
- // 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 {
- return this.saveCardToBox(userCards);
- }
-}
-
-export const atprotoOAuthService = new AtprotoOAuthService();
-export type { AtprotoSession };
diff --git a/oauth/src/services/auth.ts b/oauth/src/services/auth.ts
deleted file mode 100644
index 2011afb..0000000
--- a/oauth/src/services/auth.ts
+++ /dev/null
@@ -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 {
- try {
- const response = await axios.post(`${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 {
- 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 {
- if (!this.token) {
- return null;
- }
-
- try {
- const response = await axios.get(`${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 };
\ No newline at end of file
diff --git a/oauth_new/src/services/oauth.js b/oauth/src/services/oauth.js
similarity index 100%
rename from oauth_new/src/services/oauth.js
rename to oauth/src/services/oauth.js
diff --git a/oauth/src/styles/Card.css b/oauth/src/styles/Card.css
deleted file mode 100644
index eb4cbd5..0000000
--- a/oauth/src/styles/Card.css
+++ /dev/null
@@ -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;
-}
-
-
-
diff --git a/oauth/src/styles/CardBox.css b/oauth/src/styles/CardBox.css
deleted file mode 100644
index 2a7c61c..0000000
--- a/oauth/src/styles/CardBox.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/oauth/src/styles/CardList.css b/oauth/src/styles/CardList.css
deleted file mode 100644
index 4507634..0000000
--- a/oauth/src/styles/CardList.css
+++ /dev/null
@@ -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;
- }
-}
\ No newline at end of file
diff --git a/oauth/src/styles/CollectionAnalysis.css b/oauth/src/styles/CollectionAnalysis.css
deleted file mode 100644
index 7ff0679..0000000
--- a/oauth/src/styles/CollectionAnalysis.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/oauth/src/styles/GachaAnimation.css b/oauth/src/styles/GachaAnimation.css
deleted file mode 100644
index b068e65..0000000
--- a/oauth/src/styles/GachaAnimation.css
+++ /dev/null
@@ -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,');
- 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; }
-}
\ No newline at end of file
diff --git a/oauth/src/styles/GachaStats.css b/oauth/src/styles/GachaStats.css
deleted file mode 100644
index 4ae5aba..0000000
--- a/oauth/src/styles/GachaStats.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/oauth/src/styles/Login.css b/oauth/src/styles/Login.css
deleted file mode 100644
index f4b6747..0000000
--- a/oauth/src/styles/Login.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/oauth/src/tests/console-test.ts b/oauth/src/tests/console-test.ts
deleted file mode 100644
index ea02692..0000000
--- a/oauth/src/tests/console-test.ts
+++ /dev/null
@@ -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');
\ No newline at end of file
diff --git a/oauth/src/tests/oauth.test.ts b/oauth/src/tests/oauth.test.ts
deleted file mode 100644
index 82b708a..0000000
--- a/oauth/src/tests/oauth.test.ts
+++ /dev/null
@@ -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) => {
- 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');
-}
\ No newline at end of file
diff --git a/oauth/src/types/card.ts b/oauth/src/types/card.ts
deleted file mode 100644
index e9d7a77..0000000
--- a/oauth/src/types/card.ts
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/oauth_new/src/utils/avatar.js b/oauth/src/utils/avatar.js
similarity index 100%
rename from oauth_new/src/utils/avatar.js
rename to oauth/src/utils/avatar.js
diff --git a/oauth_new/src/utils/avatarCache.js b/oauth/src/utils/avatarCache.js
similarity index 100%
rename from oauth_new/src/utils/avatarCache.js
rename to oauth/src/utils/avatarCache.js
diff --git a/oauth_new/src/utils/avatarFetcher.js b/oauth/src/utils/avatarFetcher.js
similarity index 100%
rename from oauth_new/src/utils/avatarFetcher.js
rename to oauth/src/utils/avatarFetcher.js
diff --git a/oauth_new/src/utils/cache.js b/oauth/src/utils/cache.js
similarity index 100%
rename from oauth_new/src/utils/cache.js
rename to oauth/src/utils/cache.js
diff --git a/oauth_new/src/utils/errorHandler.js b/oauth/src/utils/errorHandler.js
similarity index 100%
rename from oauth_new/src/utils/errorHandler.js
rename to oauth/src/utils/errorHandler.js
diff --git a/oauth_new/src/utils/logger.js b/oauth/src/utils/logger.js
similarity index 100%
rename from oauth_new/src/utils/logger.js
rename to oauth/src/utils/logger.js
diff --git a/oauth/src/utils/oauth-endpoints.ts b/oauth/src/utils/oauth-endpoints.ts
deleted file mode 100644
index 62cada2..0000000
--- a/oauth/src/utils/oauth-endpoints.ts
+++ /dev/null
@@ -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 {
- 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);
-
- }
-}
\ No newline at end of file
diff --git a/oauth/src/utils/oauth-keys.ts b/oauth/src/utils/oauth-keys.ts
deleted file mode 100644
index 5ac150a..0000000
--- a/oauth/src/utils/oauth-keys.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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`
- };
-}
\ No newline at end of file
diff --git a/oauth/src/utils/pds-detection.ts b/oauth/src/utils/pds-detection.ts
deleted file mode 100644
index 74ad646..0000000
--- a/oauth/src/utils/pds-detection.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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
- }
-}
\ No newline at end of file
diff --git a/oauth_new/src/utils/pds.js b/oauth/src/utils/pds.js
similarity index 100%
rename from oauth_new/src/utils/pds.js
rename to oauth/src/utils/pds.js
diff --git a/oauth/src/utils/validation.ts b/oauth/src/utils/validation.ts
deleted file mode 100644
index dc5b8d8..0000000
--- a/oauth/src/utils/validation.ts
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/oauth/tsconfig.json b/oauth/tsconfig.json
deleted file mode 100644
index d0104ed..0000000
--- a/oauth/tsconfig.json
+++ /dev/null
@@ -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" }]
-}
\ No newline at end of file
diff --git a/oauth/tsconfig.node.json b/oauth/tsconfig.node.json
deleted file mode 100644
index 4eb43d0..0000000
--- a/oauth/tsconfig.node.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true,
- "strict": true
- },
- "include": ["vite.config.ts"]
-}
\ No newline at end of file
diff --git a/oauth_new/vite.config.js b/oauth/vite.config.js
similarity index 100%
rename from oauth_new/vite.config.js
rename to oauth/vite.config.js
diff --git a/oauth/vite.config.ts b/oauth/vite.config.ts
deleted file mode 100644
index 4a3fc95..0000000
--- a/oauth/vite.config.ts
+++ /dev/null
@@ -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 = `
-
-`
- 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' }
- ]
- }
- }
- }
-})
\ No newline at end of file
diff --git a/oauth_new/.env.production b/oauth_new/.env.production
deleted file mode 100644
index b6d20ee..0000000
--- a/oauth_new/.env.production
+++ /dev/null
@@ -1,10 +0,0 @@
-VITE_ADMIN=ai.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_REDIRECT_URI=https://syui.ai/oauth/callback
-
-# Production settings - Disable development features
-VITE_ENABLE_TEST_UI=false
-VITE_ENABLE_DEBUG=false
\ No newline at end of file
diff --git a/oauth_new/index.html b/oauth_new/index.html
deleted file mode 100644
index 5664ef4..0000000
--- a/oauth_new/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
- Comments Test
-
-
-
-
-
-
\ No newline at end of file
diff --git a/oauth_new/package.json b/oauth_new/package.json
deleted file mode 100644
index 7c7b365..0000000
--- a/oauth_new/package.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "name": "oauth-simple",
- "version": "0.2.2",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build && node build-minimal.js",
- "preview": "vite preview"
- },
- "dependencies": {
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "@atproto/api": "^0.15.12",
- "@atproto/oauth-client-browser": "^0.3.19"
- },
- "devDependencies": {
- "@types/react": "^18.2.0",
- "@types/react-dom": "^18.2.0",
- "@vitejs/plugin-react": "^4.0.0",
- "vite": "^5.0.0"
- }
-}
\ No newline at end of file
diff --git a/oauth_new/src/App.css b/oauth_new/src/App.css
deleted file mode 100644
index 4713471..0000000
--- a/oauth_new/src/App.css
+++ /dev/null
@@ -1,846 +0,0 @@
-/* Theme Colors - Match ailog style */
-:root {
- --primary: #f40;
- --primary-hover: #e03000;
- --danger: #f91880;
- --danger-hover: #d91a60;
- --success: #00ba7c;
- --warning: #ffad1f;
- --text: #1f2328;
- --text-secondary: #656d76;
- --background: #ffffff;
- --background-secondary: #f6f8fa;
- --border: #d1d9e0;
- --hover: rgba(15, 20, 25, 0.1);
-}
-
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- background: var(--background);
- color: var(--text);
- line-height: 1.6;
- font-size: 16px;
-}
-
-.app {
- min-height: 100vh;
- background: var(--background);
-}
-
-/* Header */
-.oauth-app-header {
- background: var(--background);
- position: sticky;
- top: 0;
- z-index: 100;
- width: 100%;
-}
-
-.oauth-header-content {
- display: flex;
- justify-content: flex-start;
- align-items: center;
- max-width: 800px;
- margin: 0 auto;
- padding: 20px 0;
- width: 100%;
-}
-
-.oauth-app-title {
- font-size: 20px;
- font-weight: 800;
- color: var(--text);
-}
-
-.oauth-header-actions {
- display: flex;
- gap: 8px;
- align-items: center;
- width: 100%;
-}
-
-/* Buttons */
-.btn {
- border: none;
- border-radius: 8px;
- font-weight: 700;
- font-size: 15px;
- cursor: pointer;
- transition: all 0.2s;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- text-decoration: none;
-}
-
-.btn-primary {
- background: var(--primary);
- color: white;
- padding: 8px 16px;
-}
-
-.btn-primary:hover {
- background: var(--primary-hover);
-}
-
-.btn-danger {
- background: var(--danger);
- color: white;
- padding: 8px 16px;
-}
-
-.btn-danger:hover {
- background: var(--danger-hover);
-}
-
-.btn-outline {
- background: transparent;
- color: var(--text);
- border: 1px solid var(--border);
- padding: 8px 16px;
-}
-
-.btn-outline:hover {
- background: var(--hover);
-}
-
-.btn-sm {
- padding: 4px 12px;
- font-size: 13px;
-}
-
-/* Auth Section */
-.auth-section {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.auth-section.search-bar-layout {
- display: flex;
- align-items: center;
- padding: 0;
- gap: 0;
- width: 100%;
-}
-
-.auth-section.search-bar-layout .handle-input {
- flex: 1;
- margin: 0;
- padding: 10px 15px;
- font-size: 16px;
- border: 1px solid var(--border);
- border-radius: 8px 0 0 8px;
- background: var(--background);
- outline: none;
- transition: border-color 0.2s;
- width: 100%;
- text-align: left;
- color: var(--text);
-}
-
-.auth-section.search-bar-layout .handle-input:focus {
- border-color: var(--primary);
-}
-
-.auth-section.search-bar-layout .auth-button {
- border-radius: 0 8px 8px 0;
- border: 1px solid var(--primary);
- border-left: none;
- margin: 0;
- padding: 10px 15px;
-}
-
-/* Auth Button */
-.auth-button {
- background: var(--primary);
- color: white;
- border: none;
- border-radius: 8px;
- padding: 8px 16px;
- font-weight: 700;
- cursor: pointer;
- transition: background 0.2s;
-}
-
-.auth-button:hover {
- background: var(--primary-hover);
-}
-
-.auth-button:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* Main Content */
-.main-content {
- max-width: 800px;
- margin: 0 auto;
- padding: 20px 0;
-}
-
-.content-area {
- background: var(--background);
-}
-
-/* Card Styles */
-.card {
- background: var(--background);
- border: 1px solid var(--border);
- border-radius: 8px;
- margin: 16px;
- overflow: hidden;
-}
-
-.card-header {
- padding: 16px;
- border-bottom: 1px solid var(--border);
- font-weight: 700;
- font-size: 20px;
-}
-
-.card-content {
- padding: 16px;
-}
-
-/* Comment Form */
-.comment-form {
- padding: 16px;
-}
-
-.comment-form h3 {
- font-size: 20px;
- font-weight: 800;
- margin-bottom: 16px;
-}
-
-.form-group {
- margin-bottom: 16px;
-}
-
-.form-group label {
- display: block;
- font-weight: 700;
- margin-bottom: 8px;
- color: var(--text);
-}
-
-.form-input {
- width: 100%;
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 16px;
- font-family: inherit;
- background: var(--background);
- color: var(--text);
-}
-
-.form-input:focus {
- outline: none;
- border-color: var(--primary);
-}
-
-.form-textarea {
- min-height: 120px;
- resize: vertical;
- font-family: inherit;
-}
-
-.form-actions {
- display: flex;
- justify-content: flex-end;
- margin-top: 16px;
-}
-
-/* Tab Navigation */
-.tab-header {
- display: flex;
- background: var(--background);
- overflow-x: auto;
-}
-
-.tab-btn {
- background: none;
- border: none;
- padding: 16px 20px;
- font-size: 15px;
- font-weight: 700;
- color: var(--text-secondary);
- cursor: pointer;
- border-bottom: 2px solid transparent;
- transition: color 0.2s;
- white-space: nowrap;
-}
-
-.tab-btn:hover {
- color: var(--text);
- background: var(--hover);
-}
-
-.tab-btn.active {
- color: var(--primary);
- border-bottom-color: var(--primary);
-}
-
-/* Record List */
-.record-item {
- border-bottom: 1px solid var(--border);
- padding: 16px;
- transition: background 0.2s;
- position: relative;
-}
-
-.record-item:hover {
- background: var(--background-secondary);
-}
-
-.record-header {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.avatar {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.user-info {
- flex: 1;
- min-width: 0;
-}
-
-.display-name {
- font-weight: 700;
- color: var(--text);
- font-size: 15px;
-}
-
-.handle {
- color: var(--text-secondary);
- font-size: 15px;
-}
-
-.handle-link {
- color: var(--text-secondary);
- text-decoration: none;
-}
-
-.handle-link:hover {
- color: var(--primary);
- text-decoration: underline;
-}
-
-.timestamp {
- color: var(--text-secondary);
- font-size: 13px;
- margin-top: 4px;
-}
-
-.record-actions {
- display: flex;
- gap: 8px;
- align-items: center;
-}
-
-.record-content {
- font-size: 15px;
- line-height: 1.5;
- color: var(--text);
- margin-bottom: 12px;
- white-space: pre-wrap;
- word-wrap: break-word;
-}
-
-.record-meta {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-top: 12px;
-}
-
-.record-url {
- color: var(--primary);
- text-decoration: none;
- font-size: 13px;
-}
-
-.record-url:hover {
- text-decoration: underline;
-}
-
-/* JSON Display */
-.json-display {
- margin-top: 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.json-header {
- background: var(--background-secondary);
- padding: 8px 12px;
- font-size: 13px;
- font-weight: 700;
- color: var(--text-secondary);
-}
-
-.json-content {
- background: #f8f9fa;
- padding: 12px;
- font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
- font-size: 12px;
- line-height: 1.4;
- overflow-x: auto;
- white-space: pre-wrap;
- max-height: 300px;
- overflow-y: auto;
- color: var(--text);
-}
-
-/* Ask AI */
-.ask-ai-container {
- border: 1px solid var(--border);
- border-radius: 8px;
- overflow: hidden;
- background: var(--background);
-}
-
-.ask-ai-header {
- padding: 16px;
- border-bottom: 1px solid var(--border);
- background: var(--background-secondary);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.ask-ai-header h3 {
- font-size: 20px;
- font-weight: 800;
-}
-
-.chat-container {
- height: 400px;
- overflow-y: auto;
- padding: 16px;
-}
-
-.chat-message {
- margin-bottom: 16px;
-}
-
-.user-message {
- margin-left: 40px;
-}
-
-.ai-message {
- margin-right: 40px;
-}
-
-.message-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
-}
-
-.message-content {
- background: var(--background-secondary);
- padding: 12px 16px;
- border-radius: 8px;
- font-size: 15px;
- line-height: 1.4;
-}
-
-.user-message .message-content {
- background: var(--primary);
- color: white;
-}
-
-.question-form {
- padding: 16px;
- border-top: 1px solid var(--border);
- background: var(--background);
-}
-
-.input-container {
- display: flex;
- gap: 8px;
- align-items: flex-end;
-}
-
-.question-input {
- flex: 1;
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 12px 16px;
- font-size: 16px;
- resize: none;
- font-family: inherit;
- background: var(--background);
-}
-
-.question-input:focus {
- outline: none;
- border-color: var(--primary);
-}
-
-.send-btn {
- background: var(--primary);
- color: white;
- border: none;
- border-radius: 8px;
- width: 36px;
- height: 36px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 16px;
-}
-
-.send-btn:hover:not(:disabled) {
- background: var(--primary-hover);
-}
-
-.send-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* Test UI */
-.test-ui {
- border: 2px solid var(--danger);
- border-radius: 8px;
- margin: 16px;
- background: #fff5f7;
-}
-
-.test-ui h2 {
- color: var(--danger);
- padding: 16px;
- border-bottom: 1px solid var(--border);
- margin: 0;
-}
-
-.test-ui .card-content {
- padding: 16px;
-}
-
-/* Loading Skeleton */
-.loading-skeleton {
- animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-}
-
-.skeleton-line {
- background: var(--background-secondary);
- border-radius: 4px;
- margin-bottom: 8px;
-}
-
-@keyframes pulse {
- 0%, 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.5;
- }
-}
-
-/* Error States */
-.error-message {
- background: #fef2f2;
- border: 1px solid #fecaca;
- color: #991b1b;
- padding: 12px 16px;
- border-radius: 8px;
- margin: 16px 0;
-}
-
-.success-message {
- background: #f0fdf4;
- border: 1px solid #bbf7d0;
- color: #166534;
- padding: 12px 16px;
- border-radius: 8px;
- margin: 16px 0;
-}
-
-/* Auth Notice */
-.auth-notice {
- text-align: center;
- color: var(--text-secondary);
- font-size: 14px;
- margin-top: 8px;
-}
-
-/* Page Info */
-.page-info {
- padding: 8px 16px;
- background: var(--background-secondary);
- font-size: 12px;
- color: var(--text-secondary);
- text-align: center;
-}
-
-.bottom-actions {
- padding: 20px;
- text-align: center;
- margin-top: 20px;
-}
-
-.test-section {
- margin-top: 20px;
-}
-
-/* Responsive */
-@media (max-width: 768px) {
- .main-content {
- max-width: 100%;
- }
-
- .content-area {
- border-left: none;
- border-right: none;
- }
-
- .card {
- margin: 0;
- border-radius: 0;
- border-left: none;
- border-right: none;
- }
-
- .app-header {
- padding: 8px 16px;
- }
-
- .header-actions {
- gap: 4px;
- }
-
- .btn {
- padding: 6px 12px;
- font-size: 14px;
- }
-
- .tab-btn {
- padding: 12px 16px;
- font-size: 14px;
- }
-
- .record-item {
- padding: 12px 16px;
- }
-
- .chat-container {
- height: 300px;
- }
-}
-
-/* Avatar Styles */
-.avatar {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- object-fit: cover;
- flex-shrink: 0;
- border: 1px solid var(--border);
-}
-
-.avatar-loading {
- background: var(--background-secondary);
- border-radius: 50%;
- position: relative;
- overflow: hidden;
-}
-
-.avatar-loading::after {
- content: '';
- position: absolute;
- top: 0;
- left: -100%;
- width: 100%;
- height: 100%;
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
- animation: loading-shimmer 1.5s infinite;
-}
-
-@keyframes loading-shimmer {
- 0% { left: -100%; }
- 100% { left: 100%; }
-}
-
-.avatar-fallback {
- background: var(--background-secondary);
- color: var(--text-secondary);
- font-weight: 600;
- border: 1px solid var(--border);
-}
-
-/* Avatar with Card */
-.avatar-container {
- position: relative;
- display: inline-block;
-}
-
-.avatar-card {
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- background: var(--background);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 16px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- z-index: 1000;
- min-width: 200px;
- margin-top: 8px;
-}
-
-.avatar-card::before {
- content: '';
- position: absolute;
- top: -8px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 8px solid transparent;
- border-right: 8px solid transparent;
- border-bottom: 8px solid var(--border);
-}
-
-.avatar-card::after {
- content: '';
- position: absolute;
- top: -7px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 7px solid transparent;
- border-right: 7px solid transparent;
- border-bottom: 7px solid var(--background);
-}
-
-.avatar-card-image {
- display: block;
- margin: 0 auto 12px;
-}
-
-.avatar-card-info {
- text-align: center;
-}
-
-.avatar-card-name {
- font-weight: 700;
- font-size: 16px;
- margin-bottom: 4px;
- color: var(--text);
-}
-
-.avatar-card-handle {
- color: var(--text-secondary);
- text-decoration: none;
- font-size: 14px;
-}
-
-.avatar-card-handle:hover {
- color: var(--primary);
- text-decoration: underline;
-}
-
-/* Avatar List */
-.avatar-list {
- display: flex;
- align-items: center;
-}
-
-.avatar-list-item {
- border: 2px solid var(--background);
- border-radius: 50%;
- overflow: hidden;
-}
-
-.avatar-list-more {
- border: 2px solid var(--background);
- font-weight: 600;
- font-size: 12px;
-}
-
-/* Avatar Test Styles */
-.avatar-test-container {
- margin: 16px;
-}
-
-.test-section {
- margin-bottom: 32px;
- padding-bottom: 24px;
- border-bottom: 1px solid var(--border);
-}
-
-.test-section:last-child {
- border-bottom: none;
-}
-
-.test-section h3 {
- margin-bottom: 16px;
- color: var(--text);
- font-size: 18px;
- font-weight: 700;
-}
-
-.avatar-examples {
- display: flex;
- gap: 24px;
- align-items: center;
- flex-wrap: wrap;
-}
-
-.avatar-example {
- text-align: center;
-}
-
-.avatar-example h4 {
- margin-bottom: 8px;
- font-size: 14px;
- color: var(--text-secondary);
- font-weight: 600;
-}
-
-.test-controls {
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
-}
-
-/* Utilities */
-.hidden {
- display: none;
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
\ No newline at end of file
diff --git a/scpt/run.zsh b/scpt/run.zsh
index fe0082f..ff9dfec 100755
--- a/scpt/run.zsh
+++ b/scpt/run.zsh
@@ -65,7 +65,7 @@ case "${1:-serve}" in
_oauth_build
;;
n)
- oauth=$d/oauth_new
+ oauth=$d/oauth_old
_oauth_build
;;
comment|co)