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/bin/ailog-linux-x86_64.tar.gz b/bin/ailog-linux-x86_64.tar.gz index dd0b295..f6baddb 100644 Binary files a/bin/ailog-linux-x86_64.tar.gz and b/bin/ailog-linux-x86_64.tar.gz differ 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 99% rename from oauth_new/src/App.jsx rename to oauth/src/App.jsx index 9ab8d20..5e49c65 100644 --- a/oauth_new/src/App.jsx +++ b/oauth/src/App.jsx @@ -133,6 +133,7 @@ export default function App() { { refreshAdminData?.() refreshUserData?.() 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 = ` - - ${initial} - `; - 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 ( -
-
- AI Avatar - - - {new Date(createdAt).toLocaleString()} - -
- -
-
- -
- {(postInfo?.url || value.post_url) && ( - - - {postInfo?.url || value.post_url} - - - )} -
- - {/* 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 Avatar -
-

{user.displayName || user.handle}

-

@{user.handle}

-

{user.did}

-
-
- -
- - {/* Admin Section - User Management */} - {isAdmin(user) && ( -
-

管理者機能 - ユーザーリスト管理

- - {/* User List Form */} -
-