Compare commits
39 Commits
v0.2.2
...
19c0e28668
Author | SHA1 | Date | |
---|---|---|---|
19c0e28668
|
|||
bc99eb0814
|
|||
cf93721bad
|
|||
8a8a121f4a
|
|||
be2bcae1d6
|
|||
2c08a4acfb
|
|||
7791399314
|
|||
26b1b2cf87
|
|||
7eb653f569
|
|||
0fc920c844
|
|||
13c05d97d2
|
|||
71acd44810
|
|||
1b4579d0f1
|
|||
09100f6d99
|
|||
169de9064a
|
|||
097c794623
|
|||
b652e01dd3
|
|||
31af524303
|
|||
6be024864d
|
|||
eef1fdad38
|
|||
b7e411e8b2
|
|||
8f9d803a94
|
|||
f9b9c2ab52
|
|||
210ce801f1
|
|||
6cb46f2ca1
|
|||
9406597b82
|
|||
0dbc3ba67e
|
|||
a7e6fc4a1a
|
|||
3adcfdacf5
|
|||
004081337c
|
|||
5ce0e0fd7a
|
|||
f816abb84f
|
|||
8541af9293
|
|||
68b49d5aaf
|
|||
53dab3fd09
|
|||
5fac689f98
|
|||
293421b7a5
|
|||
1793de40c1
|
|||
30bdd7b633
|
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(cargo init:*)",
|
|
||||||
"Bash(cargo:*)",
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(mkdir:*)",
|
|
||||||
"Bash(../target/debug/ailog new:*)",
|
|
||||||
"Bash(../target/debug/ailog build)",
|
|
||||||
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(pkill:*)",
|
|
||||||
"WebFetch(domain:docs.anthropic.com)",
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"Bash(rm:*)",
|
|
||||||
"Bash(mv:*)",
|
|
||||||
"Bash(cp:*)",
|
|
||||||
"Bash(timeout:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(./target/debug/ailog:*)",
|
|
||||||
"Bash(cat:*)",
|
|
||||||
"Bash(npm install)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(chmod:*)",
|
|
||||||
"Bash(./scripts/tunnel.sh:*)",
|
|
||||||
"Bash(PRODUCTION=true cargo run -- build)",
|
|
||||||
"Bash(cloudflared tunnel:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(./scripts/build-oauth-partial.zsh:*)",
|
|
||||||
"Bash(./scripts/quick-oauth-update.zsh:*)",
|
|
||||||
"Bash(../target/debug/ailog serve)",
|
|
||||||
"Bash(./scripts/test-oauth.sh:*)",
|
|
||||||
"Bash(./run.zsh:*)",
|
|
||||||
"Bash(npm run dev:*)",
|
|
||||||
"Bash(./target/release/ailog:*)",
|
|
||||||
"Bash(rg:*)",
|
|
||||||
"Bash(../target/release/ailog build)",
|
|
||||||
"Bash(zsh run.zsh:*)",
|
|
||||||
"Bash(hugo:*)",
|
|
||||||
"WebFetch(domain:docs.bsky.app)",
|
|
||||||
"WebFetch(domain:syui.ai)",
|
|
||||||
"Bash(rustup target list:*)",
|
|
||||||
"Bash(rustup target:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)",
|
|
||||||
"Bash(git tag:*)",
|
|
||||||
"Bash(../bin/ailog:*)",
|
|
||||||
"Bash(../target/release/ailog oauth build:*)",
|
|
||||||
"Bash(ailog:*)",
|
|
||||||
"WebFetch(domain:plc.directory)",
|
|
||||||
"WebFetch(domain:atproto.com)",
|
|
||||||
"WebFetch(domain:syu.is)",
|
|
||||||
"Bash(sed:*)",
|
|
||||||
"Bash(./scpt/run.zsh:*)"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
}
|
|
||||||
}
|
|
123
.gitea/workflows/cloudflare-pages.yml
Normal file
123
.gitea/workflows/cloudflare-pages.yml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
OAUTH_DIR: oauth
|
||||||
|
KEEP_DEPLOYMENTS: 5
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '21'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd ${{ env.OAUTH_DIR }}
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Build OAuth app
|
||||||
|
run: |
|
||||||
|
cd ${{ env.OAUTH_DIR }}
|
||||||
|
NODE_ENV=production npm run build
|
||||||
|
- name: Copy OAuth build to static
|
||||||
|
run: |
|
||||||
|
rm -rf my-blog/static/assets
|
||||||
|
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||||
|
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||||
|
|
||||||
|
- name: Cache ailog binary
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ./bin
|
||||||
|
key: ailog-bin-${{ runner.os }}
|
||||||
|
restore-keys: |
|
||||||
|
ailog-bin-${{ runner.os }}
|
||||||
|
|
||||||
|
- name: Setup ailog binary
|
||||||
|
run: |
|
||||||
|
# Get expected version from Cargo.toml
|
||||||
|
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
|
||||||
|
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
|
||||||
|
|
||||||
|
# Check current binary version if exists
|
||||||
|
if [ -f "./bin/ailog" ]; then
|
||||||
|
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||||
|
echo "Current binary version: $CURRENT_VERSION"
|
||||||
|
else
|
||||||
|
CURRENT_VERSION="none"
|
||||||
|
echo "No binary found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check OS
|
||||||
|
OS="${{ runner.os }}"
|
||||||
|
echo "Runner OS: $OS"
|
||||||
|
|
||||||
|
# Use pre-packaged binary if version matches or extract from tar.gz
|
||||||
|
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||||
|
echo "Binary is up to date"
|
||||||
|
chmod +x ./bin/ailog
|
||||||
|
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
|
||||||
|
echo "Extracting ailog from pre-packaged tar.gz..."
|
||||||
|
cd bin
|
||||||
|
tar -xzf ailog-linux-x86_64.tar.gz
|
||||||
|
chmod +x ailog
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Verify extracted version
|
||||||
|
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||||
|
echo "Extracted binary version: $EXTRACTED_VERSION"
|
||||||
|
|
||||||
|
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||||
|
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Error: No suitable binary found for OS: $OS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build site with ailog
|
||||||
|
run: |
|
||||||
|
cd my-blog
|
||||||
|
../bin/ailog build
|
||||||
|
|
||||||
|
- name: List public directory
|
||||||
|
run: |
|
||||||
|
ls -la my-blog/public/
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
uses: cloudflare/pages-action@v1
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||||
|
directory: my-blog/public
|
||||||
|
wranglerVersion: '3'
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: success()
|
||||||
|
steps:
|
||||||
|
- name: Cleanup old deployments
|
||||||
|
run: |
|
||||||
|
curl -X PATCH \
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
|
193
.gitea/workflows/release.yml
Normal file
193
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag (e.g., v1.0.0)'
|
||||||
|
required: true
|
||||||
|
default: 'v0.1.0'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
OPENSSL_STATIC: true
|
||||||
|
OPENSSL_VENDOR: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.target }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
artifact_name: ailog
|
||||||
|
asset_name: ailog-linux-x86_64
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
artifact_name: ailog
|
||||||
|
asset_name: ailog-linux-aarch64
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
artifact_name: ailog
|
||||||
|
asset_name: ailog-macos-x86_64
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
artifact_name: ailog
|
||||||
|
asset_name: ailog-macos-aarch64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools (Linux)
|
||||||
|
if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Configure cross-compilation (Linux ARM64)
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
|
||||||
|
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Cache target directory
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Prepare binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd target/${{ matrix.target }}/release
|
||||||
|
|
||||||
|
# Use appropriate strip command for cross-compilation
|
||||||
|
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
|
||||||
|
aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||||
|
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||||
|
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||||
|
else
|
||||||
|
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create archive
|
||||||
|
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||||
|
7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
|
||||||
|
else
|
||||||
|
tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload binary
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.asset_name }}
|
||||||
|
path: ${{ matrix.asset_name }}.tar.gz
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
echo "## What's Changed" > release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "### Features" >> release_notes.md
|
||||||
|
echo "- AI-powered static blog generator" >> release_notes.md
|
||||||
|
echo "- AtProto OAuth integration" >> release_notes.md
|
||||||
|
echo "- Automatic translation support" >> release_notes.md
|
||||||
|
echo "- AI comment system" >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "### Platforms" >> release_notes.md
|
||||||
|
echo "- Linux (x86_64, aarch64)" >> release_notes.md
|
||||||
|
echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "### Installation" >> release_notes.md
|
||||||
|
echo "\`\`\`bash" >> release_notes.md
|
||||||
|
echo "# Linux/macOS" >> release_notes.md
|
||||||
|
echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
|
||||||
|
echo "chmod +x ailog" >> release_notes.md
|
||||||
|
echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
|
||||||
|
echo "" >> release_notes.md
|
||||||
|
echo "\`\`\`" >> release_notes.md
|
||||||
|
|
||||||
|
- name: Get tag name
|
||||||
|
id: tag_name
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Release with Gitea API
|
||||||
|
run: |
|
||||||
|
# Prepare release files
|
||||||
|
mkdir -p release
|
||||||
|
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
|
||||||
|
|
||||||
|
# Create release via Gitea API
|
||||||
|
RELEASE_RESPONSE=$(curl -X POST \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
|
||||||
|
-H "Authorization: token ${{ github.token }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"tag_name": "${{ steps.tag_name.outputs.tag }}",
|
||||||
|
"name": "ailog ${{ steps.tag_name.outputs.tag }}",
|
||||||
|
"body": "'"$(cat release_notes.md | sed 's/"/\\"/g' | tr '\n' ' ')"'",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": '"$(if echo "${{ steps.tag_name.outputs.tag }}" | grep -E "(alpha|beta|rc)"; then echo "true"; else echo "false"; fi)"'
|
||||||
|
}')
|
||||||
|
|
||||||
|
# Get release ID
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||||
|
echo "Created release with ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
# Upload release assets
|
||||||
|
for file in release/*.tar.gz; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo "Uploading $filename..."
|
||||||
|
curl -X POST \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" \
|
||||||
|
-H "Authorization: token ${{ github.token }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file"
|
||||||
|
fi
|
||||||
|
done
|
11
.github/workflows/cloudflare-pages.yml
vendored
11
.github/workflows/cloudflare-pages.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OAUTH_DIR: oauth_new
|
OAUTH_DIR: oauth
|
||||||
KEEP_DEPLOYMENTS: 5
|
KEEP_DEPLOYMENTS: 5
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -114,13 +114,14 @@ jobs:
|
|||||||
needs: deploy
|
needs: deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: success()
|
if: success()
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for deployment to complete
|
|
||||||
run: sleep 3
|
|
||||||
|
|
||||||
- name: Cleanup old deployments
|
- name: Cleanup old deployments
|
||||||
run: |
|
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
|
# Get all deployments
|
||||||
DEPLOYMENTS=$(curl -s -X GET \
|
DEPLOYMENTS=$(curl -s -X GET \
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
||||||
|
9
.gitignore
vendored
9
.gitignore
vendored
@@ -16,5 +16,10 @@ my-blog/static/index.html
|
|||||||
my-blog/templates/oauth-assets.html
|
my-blog/templates/oauth-assets.html
|
||||||
cloudflared-config.yml
|
cloudflared-config.yml
|
||||||
.config
|
.config
|
||||||
oauth-server-example
|
repos
|
||||||
atproto
|
oauth_old
|
||||||
|
oauth_example
|
||||||
|
my-blog/static/oauth/assets/comment-atproto*
|
||||||
|
*.lock
|
||||||
|
my-blog/config.toml
|
||||||
|
.claude/settings.local.json
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ailog"
|
name = "ailog"
|
||||||
version = "0.2.2"
|
version = "0.2.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["syui"]
|
authors = ["syui"]
|
||||||
description = "A static blog generator with AI features"
|
description = "A static blog generator with AI features"
|
||||||
@@ -39,6 +39,8 @@ urlencoding = "2.1"
|
|||||||
axum = "0.7"
|
axum = "0.7"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
tracing = "0.1"
|
||||||
hyper = { version = "1.0", features = ["full"] }
|
hyper = { version = "1.0", features = ["full"] }
|
||||||
tower-sessions = "0.12"
|
tower-sessions = "0.12"
|
||||||
jsonwebtoken = "9.2"
|
jsonwebtoken = "9.2"
|
||||||
|
Binary file not shown.
@@ -16,10 +16,10 @@ auto_translate = false
|
|||||||
comment_moderation = false
|
comment_moderation = false
|
||||||
ask_ai = true
|
ask_ai = true
|
||||||
provider = "ollama"
|
provider = "ollama"
|
||||||
model = "qwen3"
|
model = "gemma3"
|
||||||
model_translation = "llama3.2:1b"
|
model_translation = "llama3.2:1b"
|
||||||
model_technical = "phi3:mini"
|
model_technical = "phi3:mini"
|
||||||
host = "http://localhost:11434"
|
host = "http://192.168.11.95:11434"
|
||||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||||
handle = "ai.syui.ai"
|
handle = "ai.syui.ai"
|
||||||
#num_predict = 200
|
#num_predict = 200
|
||||||
|
65
my-blog/content/posts/2025-06-19-oauth.md
Normal file
65
my-blog/content/posts/2025-06-19-oauth.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: "oauthに対応した"
|
||||||
|
slug: "oauth"
|
||||||
|
date: 2025-06-19
|
||||||
|
tags: ["atproto"]
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
現在、[syu.is](https://syu.is)に[atproto](https://github.com/bluesky-social/atproto)をselfhostしています。
|
||||||
|
|
||||||
|
oauthを`bsky.social`, `syu.is`ともに動くようにしました。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
ここでいうselfhostは、pds, plc, bsky, bgsなどを自前のserverで動かし、連携することをいいいます。
|
||||||
|
|
||||||
|
ちなみに、atprotoは[bluesky](https://bsky.app)のようなものです。
|
||||||
|
|
||||||
|
ただし、その内容は結構複雑で、`at://did`の仕組みで動くsnsです。
|
||||||
|
|
||||||
|
usernameは`handle`という`domain`の形を採用しています。
|
||||||
|
|
||||||
|
didの名前解決をしているのが`plc`です。pdsがuserのdataを保存しています。timelineに配信したり表示しているのがbsky, bgsです。
|
||||||
|
|
||||||
|
## oauthでハマったところ
|
||||||
|
|
||||||
|
現在、`bsky.team`のpds, plc, bskyには`did:plc:6qyecktefllvenje24fcxnie`が登録されています。これは`syu.is`の`@ai.syui.ai`のアカウントです。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ did=did:plc:6qyecktefllvenje24fcxnie
|
||||||
|
|
||||||
|
$ curl -sL https://plc.syu.is/$did|jq .alsoKnownAs
|
||||||
|
[ "at://ai.syui.ai" ]
|
||||||
|
|
||||||
|
$ curl -sL https://plc.directory/$did|jq .alsoKnownAs
|
||||||
|
[ "at://ai.syu.is" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
しかし、みて分かる通り、pds, plcは`@ai.syu.is`で登録されており、handle-changeが更新されていないようです。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ handle=ai.syui.ai
|
||||||
|
$ curl -sL "https://syu.is/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
|
||||||
|
$ curl -sL "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
|
||||||
|
$ curl -sL "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
|
||||||
|
```
|
||||||
|
|
||||||
|
oauthは、そのままではbsky.teamのpds, plcを使って名前解決を行います。この場合、まず、それらのserverにdidが登録されている必要があります。
|
||||||
|
|
||||||
|
次に、handleの更新が反映されている必要があります。もし反映されていない場合、handleとpasswordが一致しません。
|
||||||
|
|
||||||
|
localhostではhandleをdidにすることで突破できそうでしたが、本番環境では難しそうでした。
|
||||||
|
|
||||||
|
なお、[@atproto/oauth-provider](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-provider)の本体を書き換えて、pdsで使うと回避は可能だと思います。
|
||||||
|
|
||||||
|
私の場合は、その方法は使わず、didの名前解決には自前のpds, plcを使用することにしました。
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.oauthClient = await BrowserOAuthClient.load({
|
||||||
|
clientId: this.getClientId(),
|
||||||
|
handleResolver: pdsUrl,
|
||||||
|
plcDirectoryUrl: pdsUrl === 'https://syu.is' ? 'https://plc.syu.is' : 'https://plc.directory',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
69
my-blog/content/posts/2025-06-30-ue.md
Normal file
69
my-blog/content/posts/2025-06-30-ue.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
title: "world systemのupdateとmodelの改良"
|
||||||
|
slug: "ue"
|
||||||
|
date: 2025-06-30
|
||||||
|
tags: ["ue", "blender"]
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
最近のゲーム開発の進捗です。
|
||||||
|
|
||||||
|
## world system
|
||||||
|
|
||||||
|
現在、ue5.6で新しく世界を作り直しています。
|
||||||
|
|
||||||
|
これは、ゲーム開発のproject内でworld systemという名前をつけた惑星形式のmapを目指す領域になります。
|
||||||
|
|
||||||
|
現在、worldscape + udsでより理想に近い形のmapができました。ただ、問題もたくさんあり、重力システムと天候システムです。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[issue]
|
||||||
|
1. 天候システム
|
||||||
|
2. 重力システム
|
||||||
|
```
|
||||||
|
|
||||||
|
ですが、今までのworld systemでは、大気圏から宇宙に移行する場面や陸地が存在しない点、地平線が不完全な点などがありましたが、それらの問題はすべて解消されました。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[update]
|
||||||
|
1. 大気圏から宇宙に移行する場面が完全になった
|
||||||
|
2. 陸地ができた
|
||||||
|
3. 地平線が完全なアーチを描けるように
|
||||||
|
4. 月、惑星への着陸ができるようになった
|
||||||
|
5. 横から惑星に突入できるようになった
|
||||||
|
```
|
||||||
|
|
||||||
|
## blender
|
||||||
|
|
||||||
|
まず、昔のmodelはクオリティの関係もあって、一時的にnahidaのmodelを参考にしていました。今回はオリジナリティを強化したため、クオリティは下がりましたが、素体と衣装を別々に作り組み合わせました。また、materialも分離したため、装飾がピカピカ光るようになりました。
|
||||||
|
|
||||||
|
blenderの使い方が少しわかってきたのでやってよかったです。
|
||||||
|
|
||||||
|
> vroid(vrm) -> blender(nahida) -> blender(original)
|
||||||
|
|
||||||
|
[img-compare before="/img/ue_blender_model_ai_v0401.png" after="/img/ue_blender_model_ai_v0501.png" width="800" height="300"]
|
||||||
|
|
||||||
|
[img-compare before="/img/ue_blender_model_ai_v0402.png" after="/img/ue_blender_model_ai_v0502.png" width="800" height="300"]
|
||||||
|
|
||||||
|
特に難しかったのは、指のウェイトペイントです。これは指全体をまんべんなく塗ることで解決しました。
|
||||||
|
|
||||||
|
また、昔からあった衣装のガビガビは重複する面を削除することで解消できました。
|
||||||
|
|
||||||
|
```md
|
||||||
|
全選択(A キー)
|
||||||
|
Mesh → Clean Up → Merge by Distance
|
||||||
|
距離を0.000にして実行
|
||||||
|
```
|
||||||
|
|
||||||
|
しかし、まだまだ問題があり、細かな調整が必要です。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[issue]
|
||||||
|
1. 衣装同士、あるいは体が多少すり抜ける事がある
|
||||||
|
2. 指先、足先がちょっと気になる。ボーンの調整が完璧ではない
|
||||||
|
3. 後ろの装飾衣装を考えている。ひらひらのマントぽいものがあるといい
|
||||||
|
```
|
||||||
|
|
||||||
|
面白い動画ではありませんが、現状を記録しておきます。
|
||||||
|
|
||||||
|
<iframe width="100%" height="415" src="https://www.youtube.com/embed/K0solfQAQTQ?si=B6qD-WUODTUpWZ0y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
@@ -15,6 +15,6 @@ VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
|||||||
VITE_AI_ENABLED=true
|
VITE_AI_ENABLED=true
|
||||||
VITE_AI_ASK_AI=true
|
VITE_AI_ASK_AI=true
|
||||||
VITE_AI_PROVIDER=ollama
|
VITE_AI_PROVIDER=ollama
|
||||||
VITE_AI_MODEL=qwen3
|
VITE_AI_MODEL=gemma3
|
||||||
VITE_AI_HOST=http://localhost:11434
|
VITE_AI_HOST=http://192.168.11.95:11434
|
||||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||||
|
@@ -79,7 +79,7 @@ a.view-markdown:any-link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
max-width: 1000px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
@@ -90,7 +90,7 @@ a.view-markdown:any-link {
|
|||||||
.site-title {
|
.site-title {
|
||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 20px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ a.view-markdown:any-link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ask-ai-content {
|
.ask-ai-content {
|
||||||
max-width: 1000px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,22 +197,12 @@ a.view-markdown:any-link {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main Content */
|
|
||||||
.main-content {
|
.main-content {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
max-width: 1000px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
/* padding: 24px; */
|
padding: 0px;
|
||||||
padding-top: 80px;
|
width: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.main-content {
|
|
||||||
/* padding: 20px; */
|
|
||||||
padding: 0px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Timeline */
|
/* Timeline */
|
||||||
@@ -232,7 +222,8 @@ a.view-markdown:any-link {
|
|||||||
.timeline-feed {
|
.timeline-feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
padding: 100px 0;
|
||||||
|
/* gap: 24px; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-post {
|
.timeline-post {
|
||||||
@@ -250,7 +241,7 @@ a.view-markdown:any-link {
|
|||||||
.post-title a {
|
.post-title a {
|
||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,16 +319,12 @@ a.view-markdown:any-link {
|
|||||||
|
|
||||||
/* Article */
|
/* Article */
|
||||||
.article-container {
|
.article-container {
|
||||||
display: grid;
|
max-width: 800px;
|
||||||
grid-template-columns: 1fr 240px;
|
|
||||||
gap: 40px;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 100px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
article.article-content {
|
/* article.article-content { padding: 10px; } */
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-meta {
|
.article-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -398,18 +385,12 @@ article.article-content {
|
|||||||
border-color: var(--white);
|
border-color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar styles */
|
|
||||||
.article-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 100px;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc {
|
.toc {
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
border: 1px solid #d1d9e0;
|
border: 1px solid #d1d9e0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc h3 {
|
.toc h3 {
|
||||||
@@ -490,9 +471,39 @@ article.article-content {
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article.article-content {
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.article-body {
|
.article-body {
|
||||||
color: #1f2328;
|
color: #1f2328;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom:200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Live Text overlay fix */
|
||||||
|
.article-body div#image-overlay {
|
||||||
|
max-width: 100% !important;
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure images and their containers don't overflow */
|
||||||
|
.article-body p:has(img) {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body h1, .article-body h2, .article-body h3 {
|
.article-body h1, .article-body h2, .article-body h3 {
|
||||||
@@ -542,7 +553,7 @@ article.article-content {
|
|||||||
background: #2D2D30;
|
background: #2D2D30;
|
||||||
color: #AE81FF;
|
color: #AE81FF;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 12px;
|
font-size: 18px;
|
||||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||||
border-bottom: 1px solid #3E3D32;
|
border-bottom: 1px solid #3E3D32;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -572,7 +583,7 @@ article.article-content {
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Molokai syntax highlighting */
|
/* Molokai syntax highlighting */
|
||||||
@@ -645,7 +656,7 @@ article.article-content {
|
|||||||
.footer-social a {
|
.footer-social a {
|
||||||
color: var(--dark-gray) !important;
|
color: var(--dark-gray) !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +667,7 @@ article.article-content {
|
|||||||
|
|
||||||
.main-footer p {
|
.main-footer p {
|
||||||
color: #656d76;
|
color: #656d76;
|
||||||
font-size: 14px;
|
font-size: 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,7 +675,8 @@ article.article-content {
|
|||||||
.chat-message.comment-style {
|
.chat-message.comment-style {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d1d9e0;
|
border: 1px solid #d1d9e0;
|
||||||
border-radius: 8px;
|
border-left: 4px solid var(--theme-color);
|
||||||
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -752,8 +764,6 @@ article.article-content {
|
|||||||
.comment-section {
|
.comment-section {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
margin-top: 48px;
|
|
||||||
padding-top: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-container {
|
.comment-container {
|
||||||
@@ -787,10 +797,8 @@ article.article-content {
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.article-container {
|
.article-container {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0;
|
padding: 50px 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,46 +815,190 @@ article.article-content {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OAuth app mobile fixes */
|
/* OAuth app mobile fixes - prevent overflow and content issues */
|
||||||
.comment-item {
|
.oauth-app-header {
|
||||||
padding: 0px !important;
|
padding: 0px !important;
|
||||||
margin: 0px !important;
|
margin: 0px !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-header-content {
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 10px 20px !important;
|
||||||
|
margin: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-header-actions {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 0px !important;
|
||||||
|
margin: 0px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 0px !important;
|
||||||
|
margin: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 0px !important;
|
||||||
|
border-radius: 0px !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
padding: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item {
|
||||||
|
padding: 15px !important;
|
||||||
|
margin: 0px !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-content {
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-meta {
|
||||||
|
word-break: break-all !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-url {
|
||||||
|
word-break: break-all !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-textarea {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
padding: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-section {
|
.auth-section {
|
||||||
padding: 0px !important;
|
padding: 0px !important;
|
||||||
}
|
|
||||||
|
|
||||||
.comments-list {
|
|
||||||
padding: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-section {
|
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
padding: 0px !important;
|
overflow: hidden !important;
|
||||||
margin: 0px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-container {
|
.auth-section.search-bar-layout {
|
||||||
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
padding: 0px !important;
|
}
|
||||||
|
|
||||||
|
.auth-section.search-bar-layout .handle-input {
|
||||||
|
max-width: calc(100% - 80px) !important;
|
||||||
|
width: calc(100% - 80px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
min-width: 70px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-content {
|
||||||
|
font-size: 10px !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-container {
|
||||||
margin: 0px !important;
|
margin: 0px !important;
|
||||||
|
border-radius: 0px !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-content {
|
.chat-container {
|
||||||
padding: 10px !important;
|
height: 250px !important;
|
||||||
word-wrap: break-word !important;
|
padding: 12px !important;
|
||||||
overflow-wrap: break-word !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-header {
|
.question-form {
|
||||||
padding: 10px !important;
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Display Styles */
|
||||||
|
.profile-avatar-fallback {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--theme-color);
|
||||||
|
color: var(--white);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
background: var(--theme-color);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message, .error-message, .no-profiles {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--dark-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix comment-meta URI overflow */
|
.question-input {
|
||||||
.comment-meta {
|
width: 100% !important;
|
||||||
word-break: break-all !important;
|
box-sizing: border-box !important;
|
||||||
overflow-wrap: break-word !important;
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 44px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide site title text on mobile */
|
/* Hide site title text on mobile */
|
||||||
@@ -897,6 +1049,8 @@ article.article-content {
|
|||||||
/* Article content mobile optimization */
|
/* Article content mobile optimization */
|
||||||
.article-body {
|
.article-body {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body pre {
|
.article-body pre {
|
||||||
@@ -941,7 +1095,7 @@ article.article-content {
|
|||||||
|
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
padding: 30px 0px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-header .avatar {
|
.message-header .avatar {
|
||||||
@@ -960,4 +1114,229 @@ article.article-content {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-section {
|
||||||
|
padding: 50px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-actions {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading spinner for Ask AI panel */
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #f3f3f3;
|
||||||
|
border-top: 2px solid var(--theme-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #656d76;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle links in chat messages */
|
||||||
|
.message-header .handle a {
|
||||||
|
color: #656d76;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header .handle a:hover {
|
||||||
|
color: var(--theme-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 0px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
article.article-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.timeline-feed {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Comparison Slider Styles */
|
||||||
|
.img-comparison-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 20px auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-comparison-slider {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-before,
|
||||||
|
.img-after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-before {
|
||||||
|
z-index: 2;
|
||||||
|
clip-path: inset(0 50% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-after {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-before img,
|
||||||
|
.img-after img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 4;
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px solid var(--theme-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::after {
|
||||||
|
content: '↔';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: var(--theme-color);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.img-comparison-container {
|
||||||
|
margin: 15px auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-comparison-slider {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::before {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::after {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.img-comparison-slider {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::before {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb::after {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BIN
my-blog/static/img/atproto_oauth_syuis.png
Normal file
BIN
my-blog/static/img/atproto_oauth_syuis.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 256 KiB |
BIN
my-blog/static/img/bluecheck_ozone_socialapp.png
Normal file
BIN
my-blog/static/img/bluecheck_ozone_socialapp.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0401.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0401.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0402.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0402.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0501.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0501.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0502.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0502.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 MiB |
@@ -5,6 +5,24 @@
|
|||||||
// Global variables for AI functionality
|
// Global variables for AI functionality
|
||||||
let aiProfileData = null;
|
let aiProfileData = null;
|
||||||
|
|
||||||
|
// Get config from window or use defaults
|
||||||
|
const OAUTH_PDS = window.OAUTH_CONFIG?.pds || 'syu.is';
|
||||||
|
const ADMIN_HANDLE = window.OAUTH_CONFIG?.admin || 'ai.syui.ai';
|
||||||
|
const OAUTH_COLLECTION = window.OAUTH_CONFIG?.collection || 'ai.syui.log';
|
||||||
|
|
||||||
|
// Listen for AI profile data from OAuth app
|
||||||
|
window.addEventListener('aiProfileLoaded', function(event) {
|
||||||
|
console.log('AI profile received from OAuth app:', event.detail);
|
||||||
|
aiProfileData = event.detail;
|
||||||
|
updateAskAIButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if AI profile data is already available
|
||||||
|
if (window.aiProfileData) {
|
||||||
|
console.log('AI profile already available:', window.aiProfileData);
|
||||||
|
aiProfileData = window.aiProfileData;
|
||||||
|
}
|
||||||
|
|
||||||
// Original functions from working implementation
|
// Original functions from working implementation
|
||||||
function toggleAskAI() {
|
function toggleAskAI() {
|
||||||
const panel = document.getElementById('askAiPanel');
|
const panel = document.getElementById('askAiPanel');
|
||||||
@@ -12,24 +30,95 @@ function toggleAskAI() {
|
|||||||
panel.style.display = isVisible ? 'none' : 'block';
|
panel.style.display = isVisible ? 'none' : 'block';
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
checkAuthenticationStatus();
|
console.log('Ask AI panel opened');
|
||||||
|
|
||||||
|
// If AI profile data is already available, show introduction immediately
|
||||||
|
if (aiProfileData) {
|
||||||
|
console.log('AI profile data available - showing introduction immediately');
|
||||||
|
// Quick check for authentication
|
||||||
|
const userSections = document.querySelectorAll('.user-section');
|
||||||
|
const isAuthenticated = userSections.length > 0;
|
||||||
|
handleAuthenticationStatus(isAuthenticated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For production fallback - if OAuth app fails to load, show profiles
|
||||||
|
const isProd = window.location.hostname !== 'localhost' && !window.location.hostname.includes('preview');
|
||||||
|
if (isProd) {
|
||||||
|
console.log('Production environment detected - using fallback profile display');
|
||||||
|
// Shorter timeout for production
|
||||||
|
setTimeout(() => {
|
||||||
|
const userSections = document.querySelectorAll('.user-section');
|
||||||
|
console.log('Production check - user sections:', userSections.length);
|
||||||
|
|
||||||
|
if (userSections.length === 0) {
|
||||||
|
console.log('No user sections found in production - showing profiles directly');
|
||||||
|
handleAuthenticationStatus(false);
|
||||||
|
} else {
|
||||||
|
console.log('User sections found in production - showing authenticated UI');
|
||||||
|
handleAuthenticationStatus(true);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
checkAuthenticationStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAuthenticationStatus() {
|
function checkAuthenticationStatus() {
|
||||||
const userSections = document.querySelectorAll('.user-section');
|
// Check multiple times for OAuth app to load
|
||||||
const isAuthenticated = userSections.length > 0;
|
let checkCount = 0;
|
||||||
|
const maxChecks = 10;
|
||||||
|
|
||||||
|
const checkForAuth = () => {
|
||||||
|
console.log(`Auth check attempt ${checkCount + 1}/${maxChecks}`);
|
||||||
|
const userSections = document.querySelectorAll('.user-section');
|
||||||
|
const authButtons = document.querySelectorAll('[data-auth-status]');
|
||||||
|
const oauthContainers = document.querySelectorAll('#oauth-container');
|
||||||
|
|
||||||
|
console.log('User sections found:', userSections.length);
|
||||||
|
console.log('Auth buttons found:', authButtons.length);
|
||||||
|
console.log('OAuth containers found:', oauthContainers.length);
|
||||||
|
|
||||||
|
const isAuthenticated = userSections.length > 0;
|
||||||
|
|
||||||
|
if (isAuthenticated || checkCount >= maxChecks - 1) {
|
||||||
|
console.log('Final auth status:', isAuthenticated);
|
||||||
|
handleAuthenticationStatus(isAuthenticated);
|
||||||
|
} else {
|
||||||
|
checkCount++;
|
||||||
|
setTimeout(checkForAuth, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkForAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthenticationStatus(isAuthenticated) {
|
||||||
|
console.log('Handling auth status:', isAuthenticated);
|
||||||
|
|
||||||
|
// Always hide loading first
|
||||||
|
document.getElementById('authCheck').style.display = 'none';
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// User is authenticated - show Ask AI UI
|
// User is authenticated - show Ask AI UI
|
||||||
document.getElementById('authCheck').style.display = 'none';
|
console.log('User authenticated - showing AI chat interface');
|
||||||
document.getElementById('chatForm').style.display = 'block';
|
document.getElementById('chatForm').style.display = 'block';
|
||||||
document.getElementById('chatHistory').style.display = 'block';
|
document.getElementById('chatHistory').style.display = 'block';
|
||||||
|
|
||||||
// Show initial greeting if chat history is empty
|
// Show initial greeting if chat history is empty and AI profile is available
|
||||||
const chatHistory = document.getElementById('chatHistory');
|
const chatHistory = document.getElementById('chatHistory');
|
||||||
if (chatHistory.children.length === 0) {
|
if (chatHistory.children.length === 0) {
|
||||||
showInitialGreeting();
|
if (aiProfileData) {
|
||||||
|
showInitialGreeting();
|
||||||
|
} else {
|
||||||
|
// Wait for AI profile data
|
||||||
|
setTimeout(() => {
|
||||||
|
if (aiProfileData) {
|
||||||
|
showInitialGreeting();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus on input
|
// Focus on input
|
||||||
@@ -37,10 +126,83 @@ function checkAuthenticationStatus() {
|
|||||||
document.getElementById('aiQuestion').focus();
|
document.getElementById('aiQuestion').focus();
|
||||||
}, 50);
|
}, 50);
|
||||||
} else {
|
} else {
|
||||||
// User not authenticated - show auth message
|
// User not authenticated - show AI introduction directly if profile available
|
||||||
document.getElementById('authCheck').style.display = 'block';
|
console.log('User not authenticated - showing AI introduction');
|
||||||
document.getElementById('chatForm').style.display = 'none';
|
document.getElementById('chatForm').style.display = 'none';
|
||||||
document.getElementById('chatHistory').style.display = 'none';
|
document.getElementById('chatHistory').style.display = 'block';
|
||||||
|
|
||||||
|
if (aiProfileData) {
|
||||||
|
// Show AI introduction directly using available profile data
|
||||||
|
showAIIntroduction();
|
||||||
|
} else {
|
||||||
|
// Fallback to profile loading
|
||||||
|
loadAndShowProfiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display profiles from ai.syui.log.profile collection
|
||||||
|
async function loadAndShowProfiles() {
|
||||||
|
const chatHistory = document.getElementById('chatHistory');
|
||||||
|
chatHistory.innerHTML = '<div class="loading-message">Loading profiles...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${OAUTH_PDS}/xrpc/com.atproto.repo.listRecords?repo=${ADMIN_HANDLE}&collection=${OAUTH_COLLECTION}&limit=100`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Fetched records:', data.records);
|
||||||
|
|
||||||
|
// Filter only profile records and sort
|
||||||
|
const profileRecords = (data.records || []).filter(record => record.value.type === 'profile');
|
||||||
|
console.log('Profile records:', profileRecords);
|
||||||
|
|
||||||
|
const profiles = profileRecords.sort((a, b) => {
|
||||||
|
if (a.value.profileType === 'admin' && b.value.profileType !== 'admin') return -1;
|
||||||
|
if (a.value.profileType !== 'admin' && b.value.profileType === 'admin') return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
console.log('Sorted profiles:', profiles);
|
||||||
|
|
||||||
|
// Clear loading message
|
||||||
|
chatHistory.innerHTML = '';
|
||||||
|
|
||||||
|
// Display profiles using the same format as chat
|
||||||
|
profiles.forEach(profile => {
|
||||||
|
const profileDiv = document.createElement('div');
|
||||||
|
profileDiv.className = 'chat-message ai-message comment-style';
|
||||||
|
|
||||||
|
const avatarElement = profile.value.author.avatar
|
||||||
|
? `<img src="${profile.value.author.avatar}" alt="${profile.value.author.displayName || profile.value.author.handle}" class="profile-avatar">`
|
||||||
|
: `<div class="profile-avatar-fallback">${(profile.value.author.displayName || profile.value.author.handle || '?').charAt(0).toUpperCase()}</div>`;
|
||||||
|
|
||||||
|
const adminBadge = profile.value.profileType === 'admin'
|
||||||
|
? '<span class="admin-badge">Admin</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
profileDiv.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="avatar">${avatarElement}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="display-name">${profile.value.author.displayName || profile.value.author.handle} ${adminBadge}</div>
|
||||||
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${profile.value.author.handle}" target="_blank" rel="noopener noreferrer">@${profile.value.author.handle}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">${profile.value.text}</div>
|
||||||
|
`;
|
||||||
|
chatHistory.appendChild(profileDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
chatHistory.innerHTML = '<div class="no-profiles">No profiles available</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profiles:', error);
|
||||||
|
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +269,7 @@ function addUserMessage(question) {
|
|||||||
<div class="avatar">${userAvatar}</div>
|
<div class="avatar">${userAvatar}</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="display-name">${userDisplay}</div>
|
<div class="display-name">${userDisplay}</div>
|
||||||
<div class="handle">@${userHandle}</div>
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${userHandle}" target="_blank" rel="noopener noreferrer">@${userHandle}</a></div>
|
||||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">${question}</div>
|
<div class="message-content">${question}</div>
|
||||||
@@ -171,17 +332,57 @@ function showInitialGreeting() {
|
|||||||
<div class="avatar">${avatarElement}</div>
|
<div class="avatar">${avatarElement}</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="display-name">${aiProfileData.displayName}</div>
|
<div class="display-name">${aiProfileData.displayName}</div>
|
||||||
<div class="handle">@${aiProfileData.handle}</div>
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
|
||||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">
|
<div class="message-content">Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?</div>
|
||||||
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
chatHistory.appendChild(greetingDiv);
|
chatHistory.appendChild(greetingDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showAIIntroduction() {
|
||||||
|
if (!aiProfileData) return;
|
||||||
|
|
||||||
|
const chatHistory = document.getElementById('chatHistory');
|
||||||
|
chatHistory.innerHTML = ''; // Clear any existing content
|
||||||
|
|
||||||
|
// AI Introduction message
|
||||||
|
const introDiv = document.createElement('div');
|
||||||
|
introDiv.className = 'chat-message ai-message comment-style initial-greeting';
|
||||||
|
|
||||||
|
const avatarElement = aiProfileData.avatar
|
||||||
|
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
|
||||||
|
: '🤖';
|
||||||
|
|
||||||
|
introDiv.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="avatar">${avatarElement}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="display-name">${aiProfileData.displayName}</div>
|
||||||
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?</div>
|
||||||
|
`;
|
||||||
|
chatHistory.appendChild(introDiv);
|
||||||
|
|
||||||
|
// OAuth login message
|
||||||
|
const loginDiv = document.createElement('div');
|
||||||
|
loginDiv.className = 'chat-message user-message comment-style initial-greeting';
|
||||||
|
|
||||||
|
loginDiv.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="avatar">${avatarElement}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="display-name">${aiProfileData.displayName}</div>
|
||||||
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">Please atproto oauth login</div>
|
||||||
|
`;
|
||||||
|
chatHistory.appendChild(loginDiv);
|
||||||
|
}
|
||||||
|
|
||||||
function updateAskAIButton() {
|
function updateAskAIButton() {
|
||||||
const button = document.getElementById('askAiButton');
|
const button = document.getElementById('askAiButton');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -217,8 +418,7 @@ function handleAIResponse(responseData) {
|
|||||||
<div class="avatar">${avatarElement}</div>
|
<div class="avatar">${avatarElement}</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="display-name">${aiProfile.displayName}</div>
|
<div class="display-name">${aiProfile.displayName}</div>
|
||||||
<div class="handle">@${aiProfile.handle}</div>
|
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfile.handle}" target="_blank" rel="noopener noreferrer">@${aiProfile.handle}</a></div>
|
||||||
<div class="timestamp">${timestamp.toLocaleString()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">${responseData.answer}</div>
|
<div class="message-content">${responseData.answer}</div>
|
||||||
@@ -253,6 +453,24 @@ function setupAskAIEventListeners() {
|
|||||||
handleAIResponse(event.detail);
|
handleAIResponse(event.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for OAuth callback completion from iframe
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.data.type === 'oauth_success') {
|
||||||
|
console.log('Received OAuth success message:', event.data);
|
||||||
|
|
||||||
|
// Close any OAuth popups/iframes
|
||||||
|
const oauthFrame = document.getElementById('oauth-frame');
|
||||||
|
if (oauthFrame) {
|
||||||
|
oauthFrame.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the page to refresh OAuth app state
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Track IME composition state
|
// Track IME composition state
|
||||||
let isComposing = false;
|
let isComposing = false;
|
||||||
const aiQuestionInput = document.getElementById('aiQuestion');
|
const aiQuestionInput = document.getElementById('aiQuestion');
|
||||||
@@ -288,6 +506,37 @@ function setupAskAIEventListeners() {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
setupAskAIEventListeners();
|
setupAskAIEventListeners();
|
||||||
console.log('Ask AI initialized successfully');
|
console.log('Ask AI initialized successfully');
|
||||||
|
|
||||||
|
// Also listen for OAuth app load completion
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
// Check if user-section was added/removed
|
||||||
|
const userSectionAdded = Array.from(mutation.addedNodes).some(node =>
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
|
||||||
|
);
|
||||||
|
const userSectionRemoved = Array.from(mutation.removedNodes).some(node =>
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userSectionAdded || userSectionRemoved) {
|
||||||
|
console.log('User section status changed');
|
||||||
|
// Update Ask AI panel if it's visible
|
||||||
|
const panel = document.getElementById('askAiPanel');
|
||||||
|
if (panel && panel.style.display !== 'none') {
|
||||||
|
checkAuthenticationStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global functions for onclick handlers
|
// Global functions for onclick handlers
|
||||||
|
123
my-blog/static/js/image-comparison.js
Normal file
123
my-blog/static/js/image-comparison.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Image Comparison Slider
|
||||||
|
* UE5-style before/after image comparison component
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ImageComparison {
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container;
|
||||||
|
this.slider = container.querySelector('.slider');
|
||||||
|
this.beforeImg = container.querySelector('.img-before');
|
||||||
|
this.afterImg = container.querySelector('.img-after');
|
||||||
|
this.sliderThumb = container.querySelector('.slider-thumb');
|
||||||
|
|
||||||
|
this.isDragging = false;
|
||||||
|
this.containerRect = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.updatePosition(50); // Start at 50%
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Mouse events
|
||||||
|
this.slider.addEventListener('input', (e) => {
|
||||||
|
this.updatePosition(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.slider.addEventListener('mousedown', () => {
|
||||||
|
this.isDragging = true;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this.isDragging = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch events for mobile
|
||||||
|
this.slider.addEventListener('touchstart', (e) => {
|
||||||
|
this.isDragging = true;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.slider.addEventListener('touchmove', (e) => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.containerRect = this.container.getBoundingClientRect();
|
||||||
|
const x = touch.clientX - this.containerRect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
|
||||||
|
this.slider.value = percentage;
|
||||||
|
this.updatePosition(percentage);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.slider.addEventListener('touchend', () => {
|
||||||
|
this.isDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct click on container
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.container || e.target.classList.contains('img-comparison-slider')) {
|
||||||
|
this.containerRect = this.container.getBoundingClientRect();
|
||||||
|
const x = e.clientX - this.containerRect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
|
||||||
|
this.slider.value = percentage;
|
||||||
|
this.updatePosition(percentage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard support
|
||||||
|
this.slider.addEventListener('keydown', (e) => {
|
||||||
|
let value = parseFloat(this.slider.value);
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
value = Math.max(0, value - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
value = Math.min(100, value + 1);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
value = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
value = 100;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
this.slider.value = value;
|
||||||
|
this.updatePosition(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition(percentage) {
|
||||||
|
const position = parseFloat(percentage);
|
||||||
|
|
||||||
|
// Update clip-path for before image to show only the left portion
|
||||||
|
this.beforeImg.style.clipPath = `inset(0 ${100 - position}% 0 0)`;
|
||||||
|
|
||||||
|
// Update slider thumb position
|
||||||
|
this.sliderThumb.style.left = `${position}%`;
|
||||||
|
this.sliderThumb.style.transform = `translateX(-50%)`;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-initialize all image comparison components
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const comparisons = document.querySelectorAll('.img-comparison-container');
|
||||||
|
comparisons.forEach(container => {
|
||||||
|
new ImageComparison(container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for manual initialization
|
||||||
|
window.ImageComparison = ImageComparison;
|
3
my-blog/static/oauth/index.html
Normal file
3
my-blog/static/oauth/index.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<!-- OAuth Comment System - Load globally for session management -->
|
||||||
|
<script type="module" crossorigin src="/assets/comment-atproto-D0RrISz4.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BUFiApUA.css">
|
@@ -61,7 +61,10 @@
|
|||||||
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
||||||
<div class="ask-ai-content">
|
<div class="ask-ai-content">
|
||||||
<div id="authCheck" class="auth-check">
|
<div id="authCheck" class="auth-check">
|
||||||
<p>🔒 Please login with ATProto to use Ask AI feature</p>
|
<div class="loading-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
||||||
@@ -84,13 +87,34 @@
|
|||||||
<div class="footer-social">
|
<div class="footer-social">
|
||||||
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
|
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
|
||||||
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
|
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
|
||||||
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
|
<a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<p>© {{ config.author }}</p>
|
<p>© {{ config.author }}</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Config variables from Hugo
|
||||||
|
window.OAUTH_CONFIG = {
|
||||||
|
{% if config.oauth.pds %}
|
||||||
|
pds: "{{ config.oauth.pds }}",
|
||||||
|
{% else %}
|
||||||
|
pds: "syu.is",
|
||||||
|
{% endif %}
|
||||||
|
{% if config.oauth.admin %}
|
||||||
|
admin: "{{ config.oauth.admin }}",
|
||||||
|
{% else %}
|
||||||
|
admin: "ai.syui.ai",
|
||||||
|
{% endif %}
|
||||||
|
{% if config.oauth.collection %}
|
||||||
|
collection: "{{ config.oauth.collection }}"
|
||||||
|
{% else %}
|
||||||
|
collection: "ai.syui.log"
|
||||||
|
{% endif %}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script src="/js/ask-ai.js"></script>
|
<script src="/js/ask-ai.js"></script>
|
||||||
<script src="/js/theme.js"></script>
|
<script src="/js/theme.js"></script>
|
||||||
|
<script src="/js/image-comparison.js"></script>
|
||||||
|
|
||||||
{% include "oauth-assets.html" %}
|
{% include "oauth-assets.html" %}
|
||||||
</body>
|
</body>
|
||||||
|
@@ -27,21 +27,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="article-body">
|
|
||||||
{{ post.content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="comment-atproto"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="article-sidebar">
|
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h3>Contents</h3>
|
<h3>Contents</h3>
|
||||||
<div id="toc-content">
|
<div id="toc-content">
|
||||||
<!-- TOC will be generated by JavaScript -->
|
<!-- TOC will be generated by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
|
||||||
|
<div class="article-body">
|
||||||
|
{{ post.content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="comment-atproto"></div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@@ -1,21 +1,19 @@
|
|||||||
# Production environment variables
|
VITE_ADMIN=ai.syui.ai
|
||||||
VITE_APP_HOST=https://syui.ai
|
VITE_PDS=syu.is
|
||||||
|
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
|
||||||
|
VITE_COLLECTION=ai.syui.log
|
||||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||||
|
|
||||||
# Handle-based Configuration (DIDs resolved at runtime)
|
# AI Configuration - match oauth_old settings
|
||||||
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_ENABLED=true
|
||||||
VITE_AI_ASK_AI=true
|
VITE_AI_ASK_AI=true
|
||||||
VITE_AI_PROVIDER=ollama
|
VITE_AI_PROVIDER=ollama
|
||||||
VITE_AI_MODEL=gemma3:1b
|
VITE_AI_MODEL=gemma3:1b
|
||||||
VITE_AI_HOST=https://ollama.syui.ai
|
VITE_AI_HOST=https://ollama.syui.ai
|
||||||
|
VITE_ASK_AI_URL=https://ollama.syui.ai/api/generate
|
||||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||||
|
|
||||||
|
# Production settings - Disable development features
|
||||||
|
VITE_ENABLE_TEST_UI=false
|
||||||
|
VITE_ENABLE_DEBUG=false
|
@@ -1,20 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ja">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<title>Comments Test</title>
|
||||||
<title>ai.card</title>
|
</head>
|
||||||
<style>
|
<body>
|
||||||
body {
|
<div id="comment-atproto"></div>
|
||||||
margin: 0;
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
</body>
|
||||||
background-color: #0a0a0a;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
@@ -1,36 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "aicard",
|
"name": "ailog-oauth",
|
||||||
"version": "0.1.1",
|
"version": "0.2.5",
|
||||||
"private": true,
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode development",
|
"dev": "vite",
|
||||||
"build": "vite build --mode production",
|
"build": "vite build && node build-minimal.js",
|
||||||
"build:dev": "vite build --mode development",
|
"preview": "vite preview"
|
||||||
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
|
||||||
"preview": "npm run test:console && vite preview",
|
|
||||||
"test": "vitest",
|
|
||||||
"test:console": "node -r esbuild-register src/tests/console-test.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.12",
|
|
||||||
"@atproto/did": "^0.1.5",
|
|
||||||
"@atproto/identity": "^0.4.8",
|
|
||||||
"@atproto/oauth-client-browser": "^0.3.19",
|
|
||||||
"@atproto/xrpc": "^0.7.0",
|
|
||||||
"axios": "^1.6.2",
|
|
||||||
"framer-motion": "^10.16.16",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^7.6.1"
|
"@atproto/api": "^0.15.12",
|
||||||
|
"@atproto/oauth-client-browser": "^0.3.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"typescript": "^5.3.3",
|
"vite": "^5.0.0"
|
||||||
"vite": "^5.0.10",
|
|
||||||
"vitest": "^1.1.0",
|
|
||||||
"esbuild": "^0.19.10",
|
|
||||||
"esbuild-register": "^3.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"kty": "EC",
|
|
||||||
"crv": "P-256",
|
|
||||||
"x": "mock_x_coordinate_base64url",
|
|
||||||
"y": "mock_y_coordinate_base64url",
|
|
||||||
"d": "mock_private_key_base64url",
|
|
||||||
"use": "sig",
|
|
||||||
"kid": "ai-card-oauth-key-1",
|
|
||||||
"alg": "ES256"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"client_id": "https://syui.ai/client-metadata.json",
|
|
||||||
"client_name": "ai.log",
|
|
||||||
"client_uri": "https://syui.ai",
|
|
||||||
"logo_uri": "https://syui.ai/favicon.ico",
|
|
||||||
"tos_uri": "https://syui.ai/terms",
|
|
||||||
"policy_uri": "https://syui.ai/privacy",
|
|
||||||
"redirect_uris": [
|
|
||||||
"https://syui.ai/oauth/callback",
|
|
||||||
"https://syui.ai/"
|
|
||||||
],
|
|
||||||
"response_types": [
|
|
||||||
"code"
|
|
||||||
],
|
|
||||||
"grant_types": [
|
|
||||||
"authorization_code",
|
|
||||||
"refresh_token"
|
|
||||||
],
|
|
||||||
"token_endpoint_auth_method": "none",
|
|
||||||
"scope": "atproto transition:generic",
|
|
||||||
"subject_type": "public",
|
|
||||||
"application_type": "web",
|
|
||||||
"dpop_bound_access_tokens": true
|
|
||||||
}
|
|
2030
oauth/src/App.css
2030
oauth/src/App.css
File diff suppressed because it is too large
Load Diff
509
oauth/src/App.jsx
Normal file
509
oauth/src/App.jsx
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { atproto } from './api/atproto.js'
|
||||||
|
import { useAuth } from './hooks/useAuth.js'
|
||||||
|
import { useAdminData } from './hooks/useAdminData.js'
|
||||||
|
import { useUserData } from './hooks/useUserData.js'
|
||||||
|
import { usePageContext } from './hooks/usePageContext.js'
|
||||||
|
import AuthButton from './components/AuthButton.jsx'
|
||||||
|
import RecordTabs from './components/RecordTabs.jsx'
|
||||||
|
import CommentForm from './components/CommentForm.jsx'
|
||||||
|
import ProfileForm from './components/ProfileForm.jsx'
|
||||||
|
import AskAI from './components/AskAI.jsx'
|
||||||
|
import TestUI from './components/TestUI.jsx'
|
||||||
|
import OAuthCallback from './components/OAuthCallback.jsx'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
||||||
|
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
|
||||||
|
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
||||||
|
const [userChatRecords, setUserChatRecords] = useState([])
|
||||||
|
const [userChatLoading, setUserChatLoading] = useState(false)
|
||||||
|
const pageContext = usePageContext()
|
||||||
|
const [showAskAI, setShowAskAI] = useState(false)
|
||||||
|
const [showTestUI, setShowTestUI] = useState(false)
|
||||||
|
|
||||||
|
// Environment-based feature flags
|
||||||
|
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
||||||
|
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||||
|
|
||||||
|
// Fetch user's own chat records
|
||||||
|
const fetchUserChatRecords = async () => {
|
||||||
|
if (!user || !agent) return
|
||||||
|
|
||||||
|
setUserChatLoading(true)
|
||||||
|
try {
|
||||||
|
const records = await agent.api.com.atproto.repo.listRecords({
|
||||||
|
repo: user.did,
|
||||||
|
collection: 'ai.syui.log.chat',
|
||||||
|
limit: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group questions and answers together
|
||||||
|
const chatPairs = []
|
||||||
|
const recordMap = new Map()
|
||||||
|
|
||||||
|
// First pass: organize records by base rkey
|
||||||
|
records.data.records.forEach(record => {
|
||||||
|
const rkey = record.uri.split('/').pop()
|
||||||
|
const baseRkey = rkey.replace('-answer', '')
|
||||||
|
|
||||||
|
if (!recordMap.has(baseRkey)) {
|
||||||
|
recordMap.set(baseRkey, { question: null, answer: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.value.type === 'question') {
|
||||||
|
recordMap.get(baseRkey).question = record
|
||||||
|
} else if (record.value.type === 'answer') {
|
||||||
|
recordMap.get(baseRkey).answer = record
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second pass: create chat pairs
|
||||||
|
recordMap.forEach((pair, rkey) => {
|
||||||
|
if (pair.question) {
|
||||||
|
chatPairs.push({
|
||||||
|
rkey,
|
||||||
|
question: pair.question,
|
||||||
|
answer: pair.answer,
|
||||||
|
createdAt: pair.question.value.createdAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by creation time (newest first)
|
||||||
|
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
|
setUserChatRecords(chatPairs)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user chat records:', error)
|
||||||
|
setUserChatRecords([])
|
||||||
|
} finally {
|
||||||
|
setUserChatLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user chat records when user/agent changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserChatRecords()
|
||||||
|
}, [user, agent])
|
||||||
|
|
||||||
|
// Expose AI profile data to blog's ask-ai.js
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminData?.profile) {
|
||||||
|
console.log('AI profile loaded:', adminData.profile)
|
||||||
|
|
||||||
|
// Make AI profile data available globally for ask-ai.js
|
||||||
|
window.aiProfileData = {
|
||||||
|
did: adminData.did,
|
||||||
|
handle: adminData.profile.handle,
|
||||||
|
displayName: adminData.profile.displayName,
|
||||||
|
avatar: adminData.profile.avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event to notify ask-ai.js
|
||||||
|
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
|
||||||
|
detail: window.aiProfileData
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [adminData])
|
||||||
|
|
||||||
|
// Event listeners for blog communication
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear OAuth completion flag once app is loaded
|
||||||
|
if (sessionStorage.getItem('oauth_just_completed') === 'true') {
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionStorage.removeItem('oauth_just_completed')
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAIQuestion = async (event) => {
|
||||||
|
const { question } = event.detail
|
||||||
|
if (question && adminData && user && agent) {
|
||||||
|
try {
|
||||||
|
console.log('Processing AI question:', question)
|
||||||
|
|
||||||
|
// AI設定
|
||||||
|
const aiConfig = {
|
||||||
|
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
|
||||||
|
model: import.meta.env.VITE_AI_MODEL || 'gemma3:1b',
|
||||||
|
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。'
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `${aiConfig.systemPrompt}
|
||||||
|
|
||||||
|
Question: ${question}
|
||||||
|
|
||||||
|
Answer:`
|
||||||
|
|
||||||
|
// Ollamaに直接リクエスト
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||||
|
|
||||||
|
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,
|
||||||
|
repeat_penalty: 1.1,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const answer = data.response || 'エラーが発生しました'
|
||||||
|
|
||||||
|
console.log('AI response received:', answer)
|
||||||
|
|
||||||
|
// Save conversation to ATProto
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const timestamp = now.toISOString()
|
||||||
|
const rkey = timestamp.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', '') || ''
|
||||||
|
|
||||||
|
// 1. Save question record
|
||||||
|
const questionRecord = {
|
||||||
|
$type: 'ai.syui.log.chat',
|
||||||
|
post: {
|
||||||
|
url: currentUrl,
|
||||||
|
slug: postSlug,
|
||||||
|
title: postTitle,
|
||||||
|
date: timestamp,
|
||||||
|
tags: [],
|
||||||
|
language: "ja"
|
||||||
|
},
|
||||||
|
type: "question",
|
||||||
|
text: question,
|
||||||
|
author: {
|
||||||
|
did: user.did,
|
||||||
|
handle: user.handle,
|
||||||
|
displayName: user.displayName || user.handle,
|
||||||
|
avatar: user.avatar
|
||||||
|
},
|
||||||
|
createdAt: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
|
repo: user.did,
|
||||||
|
collection: 'ai.syui.log.chat',
|
||||||
|
rkey: rkey,
|
||||||
|
record: questionRecord
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Save answer record
|
||||||
|
const answerRkey = rkey + '-answer'
|
||||||
|
const answerRecord = {
|
||||||
|
$type: 'ai.syui.log.chat',
|
||||||
|
post: {
|
||||||
|
url: currentUrl,
|
||||||
|
slug: postSlug,
|
||||||
|
title: postTitle,
|
||||||
|
date: timestamp,
|
||||||
|
tags: [],
|
||||||
|
language: "ja"
|
||||||
|
},
|
||||||
|
type: "answer",
|
||||||
|
text: answer,
|
||||||
|
author: {
|
||||||
|
did: adminData.did,
|
||||||
|
handle: adminData.profile?.handle,
|
||||||
|
displayName: adminData.profile?.displayName,
|
||||||
|
avatar: adminData.profile?.avatar
|
||||||
|
},
|
||||||
|
createdAt: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
|
repo: user.did,
|
||||||
|
collection: 'ai.syui.log.chat',
|
||||||
|
rkey: answerRkey,
|
||||||
|
record: answerRecord
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Question and answer saved to ATProto')
|
||||||
|
|
||||||
|
// Refresh chat records after saving
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchUserChatRecords()
|
||||||
|
}, 1000)
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('Failed to save conversation:', saveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response to blog
|
||||||
|
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
|
||||||
|
detail: {
|
||||||
|
question: question,
|
||||||
|
answer: answer,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
aiProfile: adminData?.profile ? {
|
||||||
|
did: adminData.did,
|
||||||
|
handle: adminData.profile.handle,
|
||||||
|
displayName: adminData.profile.displayName,
|
||||||
|
avatar: adminData.profile.avatar
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process AI question:', error)
|
||||||
|
// Send error response to blog
|
||||||
|
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
|
||||||
|
detail: {
|
||||||
|
question: question,
|
||||||
|
answer: 'エラーが発生しました。もう一度お試しください。',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
aiProfile: adminData?.profile ? {
|
||||||
|
did: adminData.did,
|
||||||
|
handle: adminData.profile.handle,
|
||||||
|
displayName: adminData.profile.displayName,
|
||||||
|
avatar: adminData.profile.avatar
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchAIProfileLoaded = () => {
|
||||||
|
if (adminData?.profile) {
|
||||||
|
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
|
||||||
|
detail: {
|
||||||
|
did: adminData.did,
|
||||||
|
handle: adminData.profile.handle,
|
||||||
|
displayName: adminData.profile.displayName,
|
||||||
|
avatar: adminData.profile.avatar
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for questions from blog
|
||||||
|
window.addEventListener('postAIQuestion', handleAIQuestion)
|
||||||
|
|
||||||
|
// Dispatch AI profile when adminData is available
|
||||||
|
if (adminData?.profile) {
|
||||||
|
dispatchAIProfileLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('postAIQuestion', handleAIQuestion)
|
||||||
|
}
|
||||||
|
}, [adminData, user, agent])
|
||||||
|
|
||||||
|
// Handle OAuth callback
|
||||||
|
if (window.location.search.includes('code=')) {
|
||||||
|
return <OAuthCallback />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = authLoading || dataLoading || userLoading
|
||||||
|
|
||||||
|
// Don't show loading if we just completed OAuth callback
|
||||||
|
const isOAuthReturn = window.location.pathname === '/oauth/callback' ||
|
||||||
|
sessionStorage.getItem('oauth_just_completed') === 'true'
|
||||||
|
|
||||||
|
if (isLoading && !isOAuthReturn) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '200px',
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
border: '4px solid #f3f3f3',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}} />
|
||||||
|
<p style={{ color: '#666', margin: 0 }}>読み込み中...</p>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||||
|
<h1>エラー</h1>
|
||||||
|
<div style={{
|
||||||
|
background: '#fee',
|
||||||
|
color: '#c33',
|
||||||
|
padding: '15px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
margin: '20px auto',
|
||||||
|
maxWidth: '500px',
|
||||||
|
border: '1px solid #fcc'
|
||||||
|
}}>
|
||||||
|
<p><strong>エラー:</strong> {error}</p>
|
||||||
|
{retryCount > 0 && (
|
||||||
|
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refreshAdminData}
|
||||||
|
style={{
|
||||||
|
background: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
再読み込み
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="oauth-app-header">
|
||||||
|
<div className="oauth-header-content">
|
||||||
|
{user && (
|
||||||
|
<div className="oauth-user-profile">
|
||||||
|
<div className="profile-avatar-section">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.displayName || user.handle}
|
||||||
|
className="profile-avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="profile-avatar-fallback">
|
||||||
|
{(user.displayName || user.handle || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-display-name">
|
||||||
|
{user.displayName || user.handle}
|
||||||
|
</div>
|
||||||
|
<div className="profile-handle">
|
||||||
|
@{user.handle}
|
||||||
|
</div>
|
||||||
|
<div className="profile-did">
|
||||||
|
{user.did}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="oauth-header-actions">
|
||||||
|
<AuthButton
|
||||||
|
user={user}
|
||||||
|
onLogin={login}
|
||||||
|
onLogout={logout}
|
||||||
|
loading={authLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="main-content">
|
||||||
|
<div className="content-area">
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="comment-form">
|
||||||
|
<CommentForm
|
||||||
|
user={user}
|
||||||
|
agent={agent}
|
||||||
|
onCommentPosted={() => {
|
||||||
|
refreshAdminData?.()
|
||||||
|
refreshUserData?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="profile-form">
|
||||||
|
<ProfileForm
|
||||||
|
user={user}
|
||||||
|
agent={agent}
|
||||||
|
apiConfig={adminData.apiConfig}
|
||||||
|
onProfilePosted={() => {
|
||||||
|
refreshAdminData?.()
|
||||||
|
refreshUserData?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecordTabs
|
||||||
|
langRecords={langRecords}
|
||||||
|
commentRecords={commentRecords}
|
||||||
|
userComments={userComments}
|
||||||
|
chatRecords={chatRecords}
|
||||||
|
userChatRecords={userChatRecords}
|
||||||
|
userChatLoading={userChatLoading}
|
||||||
|
baseRecords={adminData.records}
|
||||||
|
apiConfig={adminData.apiConfig}
|
||||||
|
pageContext={pageContext}
|
||||||
|
user={user}
|
||||||
|
agent={agent}
|
||||||
|
onRecordDeleted={() => {
|
||||||
|
refreshAdminData?.()
|
||||||
|
refreshUserData?.()
|
||||||
|
fetchUserChatRecords?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ENABLE_TEST_UI && showTestUI && (
|
||||||
|
<div className="test-section">
|
||||||
|
<TestUI />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ENABLE_TEST_UI && (
|
||||||
|
<div className="bottom-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTestUI(!showTestUI)}
|
||||||
|
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
|
||||||
|
>
|
||||||
|
{showTestUI ? 'close test' : 'test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bluesky-footer">
|
||||||
|
<i className="fab fa-bluesky"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
1622
oauth/src/App.tsx
1622
oauth/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -58,12 +58,34 @@ async function request(url, options = {}) {
|
|||||||
|
|
||||||
export const atproto = {
|
export const atproto = {
|
||||||
async getDid(pds, handle) {
|
async getDid(pds, handle) {
|
||||||
const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
const endpoint = pds.startsWith('http') ? pds : `https://${pds}`
|
||||||
|
const res = await request(`${endpoint}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
||||||
return res.did
|
return res.did
|
||||||
},
|
},
|
||||||
|
|
||||||
async getProfile(bsky, actor) {
|
async getProfile(bsky, actor) {
|
||||||
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
// Skip test DIDs
|
||||||
|
if (actor && actor.includes('test-')) {
|
||||||
|
console.log('Skipping profile fetch for test DID:', actor)
|
||||||
|
return {
|
||||||
|
did: actor,
|
||||||
|
handle: 'test.user',
|
||||||
|
displayName: 'Test User',
|
||||||
|
avatar: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if endpoint supports getProfile
|
||||||
|
let apiEndpoint = bsky
|
||||||
|
|
||||||
|
// Allow public.api.bsky.app and bsky.syu.is, redirect other PDS endpoints
|
||||||
|
if (!bsky.includes('public.api.bsky.app') && !bsky.includes('bsky.syu.is')) {
|
||||||
|
// If it's a PDS endpoint that doesn't support getProfile, redirect to public API
|
||||||
|
console.warn(`getProfile called with PDS endpoint ${bsky}, redirecting to public API`)
|
||||||
|
apiEndpoint = 'https://public.api.bsky.app'
|
||||||
|
}
|
||||||
|
|
||||||
|
return await request(`${apiEndpoint}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRecords(pds, repo, collection, limit = 10) {
|
async getRecords(pds, repo, collection, limit = 10) {
|
||||||
@@ -158,6 +180,16 @@ export const collections = {
|
|||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getProfiles(pds, repo, collection, limit = 100) {
|
||||||
|
const cacheKey = dataCache.generateKey('profiles', pds, repo, collection, limit)
|
||||||
|
const cached = dataCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
|
||||||
|
dataCache.set(cacheKey, data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
// 投稿後にキャッシュを無効化
|
// 投稿後にキャッシュを無効化
|
||||||
invalidateCache(collection) {
|
invalidateCache(collection) {
|
||||||
dataCache.invalidatePattern(collection)
|
dataCache.invalidatePattern(collection)
|
@@ -1,21 +0,0 @@
|
|||||||
// Cloudflare Access対応版の例
|
|
||||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
// Cloudflare Access Service Token
|
|
||||||
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
|
|
||||||
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: aiConfig.model,
|
|
||||||
prompt: prompt,
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.9,
|
|
||||||
top_p: 0.9,
|
|
||||||
num_predict: 200,
|
|
||||||
repeat_penalty: 1.1,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
@@ -1,271 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { User } from '../services/auth';
|
|
||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
|
||||||
import { appConfig, getCollectionNames } from '../config/app';
|
|
||||||
|
|
||||||
interface AIChatProps {
|
|
||||||
user: User | null;
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
|
||||||
const [chatHistory, setChatHistory] = useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [aiProfile, setAiProfile] = useState<any>(null);
|
|
||||||
|
|
||||||
// Get AI settings from appConfig (unified configuration)
|
|
||||||
const aiConfig = {
|
|
||||||
enabled: appConfig.aiEnabled,
|
|
||||||
askAi: appConfig.aiAskAi,
|
|
||||||
provider: appConfig.aiProvider,
|
|
||||||
model: appConfig.aiModel,
|
|
||||||
host: appConfig.aiHost,
|
|
||||||
systemPrompt: appConfig.aiSystemPrompt,
|
|
||||||
aiDid: appConfig.aiDid,
|
|
||||||
bskyPublicApi: appConfig.bskyPublicApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch AI profile on load
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAIProfile = async () => {
|
|
||||||
if (!aiConfig.aiDid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try with agent first
|
|
||||||
const agent = atprotoOAuthService.getAgent();
|
|
||||||
if (agent) {
|
|
||||||
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
|
|
||||||
const profileData = {
|
|
||||||
did: aiConfig.aiDid,
|
|
||||||
handle: profile.data.handle,
|
|
||||||
displayName: profile.data.displayName,
|
|
||||||
avatar: profile.data.avatar,
|
|
||||||
description: profile.data.description
|
|
||||||
};
|
|
||||||
setAiProfile(profileData);
|
|
||||||
|
|
||||||
// Dispatch event to update Ask AI button
|
|
||||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to public API
|
|
||||||
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const profileData = await response.json();
|
|
||||||
const profile = {
|
|
||||||
did: aiConfig.aiDid,
|
|
||||||
handle: profileData.handle,
|
|
||||||
displayName: profileData.displayName,
|
|
||||||
avatar: profileData.avatar,
|
|
||||||
description: profileData.description
|
|
||||||
};
|
|
||||||
setAiProfile(profile);
|
|
||||||
|
|
||||||
// Dispatch event to update Ask AI button
|
|
||||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setAiProfile(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAIProfile();
|
|
||||||
}, [aiConfig.aiDid]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEnabled || !aiConfig.askAi) return;
|
|
||||||
|
|
||||||
// Listen for AI question posts from base.html
|
|
||||||
const handleAIQuestion = async (event: any) => {
|
|
||||||
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
|
||||||
await postQuestionAndGenerateResponse(event.detail.question);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add listener with a small delay to ensure it's ready
|
|
||||||
setTimeout(() => {
|
|
||||||
window.addEventListener('postAIQuestion', handleAIQuestion);
|
|
||||||
|
|
||||||
// Notify that AI is ready
|
|
||||||
window.dispatchEvent(new CustomEvent('aiChatReady'));
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('postAIQuestion', handleAIQuestion);
|
|
||||||
};
|
|
||||||
}, [user, isEnabled, isProcessing, aiProfile]);
|
|
||||||
|
|
||||||
const postQuestionAndGenerateResponse = async (question: string) => {
|
|
||||||
if (!user || !aiConfig.askAi || !aiProfile) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = atprotoOAuthService.getAgent();
|
|
||||||
if (!agent) throw new Error('No agent available');
|
|
||||||
|
|
||||||
// Get collection names
|
|
||||||
const collections = getCollectionNames(appConfig.collections.base);
|
|
||||||
|
|
||||||
// 1. Post question to ATProto
|
|
||||||
const now = new Date();
|
|
||||||
const rkey = now.toISOString().replace(/[:.]/g, '-');
|
|
||||||
|
|
||||||
// Extract post metadata from current page
|
|
||||||
const currentUrl = window.location.href;
|
|
||||||
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
|
|
||||||
const postTitle = document.title.replace(' - syui.ai', '') || '';
|
|
||||||
|
|
||||||
const questionRecord = {
|
|
||||||
$type: collections.chat,
|
|
||||||
post: {
|
|
||||||
url: currentUrl,
|
|
||||||
slug: postSlug,
|
|
||||||
title: postTitle,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
tags: [],
|
|
||||||
language: "ja"
|
|
||||||
},
|
|
||||||
type: "question",
|
|
||||||
text: question,
|
|
||||||
author: {
|
|
||||||
did: user.did,
|
|
||||||
handle: user.handle,
|
|
||||||
avatar: user.avatar,
|
|
||||||
displayName: user.displayName || user.handle,
|
|
||||||
},
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await agent.api.com.atproto.repo.putRecord({
|
|
||||||
repo: user.did,
|
|
||||||
collection: collections.chat,
|
|
||||||
rkey: rkey,
|
|
||||||
record: questionRecord,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Get chat history
|
|
||||||
const chatRecords = await agent.api.com.atproto.repo.listRecords({
|
|
||||||
repo: user.did,
|
|
||||||
collection: collections.chat,
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
let chatHistoryText = '';
|
|
||||||
if (chatRecords.data.records) {
|
|
||||||
chatHistoryText = chatRecords.data.records
|
|
||||||
.map((r: any) => {
|
|
||||||
if (r.value.type === 'question') {
|
|
||||||
return `User: ${r.value.text}`;
|
|
||||||
} else if (r.value.type === 'answer') {
|
|
||||||
return `AI: ${r.value.text}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Generate AI response based on provider
|
|
||||||
let aiAnswer = '';
|
|
||||||
|
|
||||||
// 3. Generate AI response using Ollama via proxy
|
|
||||||
if (aiConfig.provider === 'ollama') {
|
|
||||||
const prompt = `${aiConfig.systemPrompt}
|
|
||||||
|
|
||||||
Question: ${question}
|
|
||||||
|
|
||||||
Answer:`;
|
|
||||||
|
|
||||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Origin': 'https://syui.ai',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: aiConfig.model,
|
|
||||||
prompt: prompt,
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.9,
|
|
||||||
top_p: 0.9,
|
|
||||||
num_predict: 200, // Longer responses for better answers
|
|
||||||
repeat_penalty: 1.1,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('AI API request failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
aiAnswer = data.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Immediately dispatch event to update UI
|
|
||||||
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
|
|
||||||
detail: {
|
|
||||||
answer: aiAnswer,
|
|
||||||
aiProfile: aiProfile,
|
|
||||||
timestamp: now.toISOString()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 5. Save AI response in background
|
|
||||||
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
|
|
||||||
|
|
||||||
const answerRecord = {
|
|
||||||
$type: collections.chat,
|
|
||||||
post: {
|
|
||||||
url: currentUrl,
|
|
||||||
slug: postSlug,
|
|
||||||
title: postTitle,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
tags: [],
|
|
||||||
language: "ja"
|
|
||||||
},
|
|
||||||
type: "answer",
|
|
||||||
text: aiAnswer,
|
|
||||||
author: {
|
|
||||||
did: aiProfile.did,
|
|
||||||
handle: aiProfile.handle,
|
|
||||||
displayName: aiProfile.displayName,
|
|
||||||
avatar: aiProfile.avatar,
|
|
||||||
},
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save to ATProto asynchronously (don't wait for it)
|
|
||||||
agent.api.com.atproto.repo.putRecord({
|
|
||||||
repo: user.did,
|
|
||||||
collection: collections.chat,
|
|
||||||
rkey: answerRkey,
|
|
||||||
record: answerRecord,
|
|
||||||
}).catch(err => {
|
|
||||||
// Silent fail for AI response saving
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
window.dispatchEvent(new CustomEvent('aiResponseError', {
|
|
||||||
detail: { error: 'AI応答の生成に失敗しました' }
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// This component doesn't render anything - it just handles the logic
|
|
||||||
return null;
|
|
||||||
};
|
|
@@ -1,79 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { AtprotoAgent } from '@atproto/api';
|
|
||||||
|
|
||||||
interface AIProfile {
|
|
||||||
did: string;
|
|
||||||
handle: string;
|
|
||||||
displayName?: string;
|
|
||||||
avatar?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AIProfileProps {
|
|
||||||
aiDid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
|
|
||||||
const [profile, setProfile] = useState<AIProfile | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAIProfile = async () => {
|
|
||||||
try {
|
|
||||||
// Use public API to get profile information
|
|
||||||
const agent = new AtprotoAgent({ service: 'https://bsky.social' });
|
|
||||||
const response = await agent.getProfile({ actor: aiDid });
|
|
||||||
|
|
||||||
setProfile({
|
|
||||||
did: response.data.did,
|
|
||||||
handle: response.data.handle,
|
|
||||||
displayName: response.data.displayName,
|
|
||||||
avatar: response.data.avatar,
|
|
||||||
description: response.data.description,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Failed to fetch AI profile
|
|
||||||
// Fallback to basic info
|
|
||||||
setProfile({
|
|
||||||
did: aiDid,
|
|
||||||
handle: 'ai-assistant',
|
|
||||||
displayName: 'AI Assistant',
|
|
||||||
description: 'AI assistant for this blog',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (aiDid) {
|
|
||||||
fetchAIProfile();
|
|
||||||
}
|
|
||||||
}, [aiDid]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="ai-profile-loading">Loading AI profile...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ai-profile">
|
|
||||||
<div className="ai-avatar">
|
|
||||||
{profile.avatar ? (
|
|
||||||
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
|
|
||||||
) : (
|
|
||||||
<div className="ai-avatar-placeholder">🤖</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ai-info">
|
|
||||||
<div className="ai-name">{profile.displayName || profile.handle}</div>
|
|
||||||
<div className="ai-handle">@{profile.handle}</div>
|
|
||||||
{profile.description && (
|
|
||||||
<div className="ai-description">{profile.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -57,15 +57,15 @@ export default function AskAI({ adminData, user, agent, onClose }) {
|
|||||||
<div className="user-message">
|
<div className="user-message">
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
{entry.user?.avatar ? (
|
{(entry.user?.avatar || user?.avatar) ? (
|
||||||
<img src={entry.user.avatar} alt={entry.user.displayName} className="profile-avatar" />
|
<img src={entry.user?.avatar || user?.avatar} alt={entry.user?.displayName || user?.displayName} className="profile-avatar" />
|
||||||
) : (
|
) : (
|
||||||
'👤'
|
'👤'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<div className="display-name">{entry.user?.displayName || 'You'}</div>
|
<div className="display-name">{entry.user?.displayName || user?.displayName || 'You'}</div>
|
||||||
<div className="handle">@{entry.user?.handle || 'user'}</div>
|
<div className="handle">@{entry.user?.handle || user?.handle || 'user'}</div>
|
||||||
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@@ -25,20 +25,20 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div className="user-section" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{user.avatar && (
|
{user.avatar && (
|
||||||
<img
|
<img
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="avatar"
|
className="user-avatar"
|
||||||
style={{ width: '24px', height: '24px' }}
|
style={{ width: '24px', height: '24px' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
<div className="user-display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="handle" style={{ fontSize: '12px' }}>
|
<div className="user-handle" style={{ fontSize: '12px' }}>
|
||||||
@{user.handle}
|
@{user.handle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={handleInput}
|
value={handleInput}
|
||||||
onChange={(e) => setHandleInput(e.target.value)}
|
onChange={(e) => setHandleInput(e.target.value)}
|
||||||
placeholder="your.handle.com"
|
placeholder="user.bsky.social"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="handle-input"
|
className="handle-input"
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
@@ -70,7 +70,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
|||||||
disabled={isLoading || !handleInput.trim()}
|
disabled={isLoading || !handleInput.trim()}
|
||||||
className="auth-button"
|
className="auth-button"
|
||||||
>
|
>
|
||||||
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
|
{isLoading ? 'Loading...' : <i className="fab fa-bluesky"></i>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
@@ -1,120 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { Card as CardType, CardRarity } from '../types/card';
|
|
||||||
import '../styles/Card.css';
|
|
||||||
|
|
||||||
interface CardProps {
|
|
||||||
card: CardType;
|
|
||||||
isRevealing?: boolean;
|
|
||||||
detailed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CARD_INFO: Record<number, { name: string; color: string }> = {
|
|
||||||
0: { name: "アイ", color: "#fff700" },
|
|
||||||
1: { name: "夢幻", color: "#b19cd9" },
|
|
||||||
2: { name: "光彩", color: "#ffd700" },
|
|
||||||
3: { name: "中性子", color: "#cacfd2" },
|
|
||||||
4: { name: "太陽", color: "#ff6b35" },
|
|
||||||
5: { name: "夜空", color: "#1a1a2e" },
|
|
||||||
6: { name: "雪", color: "#e3f2fd" },
|
|
||||||
7: { name: "雷", color: "#ffd93d" },
|
|
||||||
8: { name: "超究", color: "#6c5ce7" },
|
|
||||||
9: { name: "剣", color: "#a8e6cf" },
|
|
||||||
10: { name: "破壊", color: "#ff4757" },
|
|
||||||
11: { name: "地球", color: "#4834d4" },
|
|
||||||
12: { name: "天の川", color: "#9c88ff" },
|
|
||||||
13: { name: "創造", color: "#00d2d3" },
|
|
||||||
14: { name: "超新星", color: "#ff9ff3" },
|
|
||||||
15: { name: "世界", color: "#54a0ff" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
|
|
||||||
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
|
|
||||||
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
|
|
||||||
|
|
||||||
const getRarityClass = () => {
|
|
||||||
switch (card.status) {
|
|
||||||
case CardRarity.UNIQUE:
|
|
||||||
return 'card-unique';
|
|
||||||
case CardRarity.KIRA:
|
|
||||||
return 'card-kira';
|
|
||||||
case CardRarity.SUPER_RARE:
|
|
||||||
return 'card-super-rare';
|
|
||||||
case CardRarity.RARE:
|
|
||||||
return 'card-rare';
|
|
||||||
default:
|
|
||||||
return 'card-normal';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!detailed) {
|
|
||||||
// Simple view - only image and frame
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`card card-simple ${getRarityClass()}`}
|
|
||||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
|
||||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, type: "spring" }}
|
|
||||||
>
|
|
||||||
<div className="card-frame">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={cardInfo.name}
|
|
||||||
className="card-image-simple"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed view - all information
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={`card ${getRarityClass()}`}
|
|
||||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
|
||||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, type: "spring" }}
|
|
||||||
style={{
|
|
||||||
'--card-color': cardInfo.color,
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<div className="card-inner">
|
|
||||||
<div className="card-header">
|
|
||||||
<span className="card-id">#{card.id}</span>
|
|
||||||
<span className="card-cp">CP: {card.cp}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-image-container">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={cardInfo.name}
|
|
||||||
className="card-image"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-content">
|
|
||||||
<h3 className="card-name">{cardInfo.name}</h3>
|
|
||||||
{card.is_unique && (
|
|
||||||
<div className="unique-badge">UNIQUE</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{card.skill && (
|
|
||||||
<div className="card-skill">
|
|
||||||
<p>{card.skill}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card-footer">
|
|
||||||
<span className="card-rarity">{card.status.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,171 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
|
||||||
import { Card } from './Card';
|
|
||||||
import '../styles/CardBox.css';
|
|
||||||
|
|
||||||
interface CardBoxProps {
|
|
||||||
userDid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
|
|
||||||
const [boxData, setBoxData] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showJson, setShowJson] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBoxData();
|
|
||||||
}, [userDid]);
|
|
||||||
|
|
||||||
const loadBoxData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await atprotoOAuthService.getCardsFromBox();
|
|
||||||
setBoxData(data);
|
|
||||||
} catch (err) {
|
|
||||||
// Failed to load card box
|
|
||||||
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveToBox = async () => {
|
|
||||||
// 現在のカードデータを取得してボックスに保存
|
|
||||||
// この部分は親コンポーネントから渡すか、APIから取得する必要があります
|
|
||||||
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteBox = async () => {
|
|
||||||
if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await atprotoOAuthService.deleteCardBox();
|
|
||||||
setBoxData({ records: [] });
|
|
||||||
alert('カードボックスを削除しました');
|
|
||||||
} catch (err) {
|
|
||||||
// Failed to delete card box
|
|
||||||
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="card-box-container">
|
|
||||||
<div className="loading">カードボックスを読み込み中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="card-box-container">
|
|
||||||
<div className="error">エラー: {error}</div>
|
|
||||||
<button onClick={loadBoxData} className="retry-button">
|
|
||||||
再試行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const records = boxData?.records || [];
|
|
||||||
const selfRecord = records.find((record: any) => record.uri.includes('/self'));
|
|
||||||
const cards = selfRecord?.value?.cards || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card-box-container">
|
|
||||||
<div className="card-box-header">
|
|
||||||
<h3>📦 atproto カードボックス</h3>
|
|
||||||
<div className="box-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowJson(!showJson)}
|
|
||||||
className="json-button"
|
|
||||||
>
|
|
||||||
{showJson ? 'JSON非表示' : 'JSON表示'}
|
|
||||||
</button>
|
|
||||||
<button onClick={loadBoxData} className="refresh-button">
|
|
||||||
🔄 更新
|
|
||||||
</button>
|
|
||||||
{cards.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteBox}
|
|
||||||
className="delete-button"
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? '削除中...' : '🗑️ 削除'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="uri-display">
|
|
||||||
<p>
|
|
||||||
<strong>📍 URI:</strong>
|
|
||||||
<code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showJson && (
|
|
||||||
<div className="json-display">
|
|
||||||
<h4>Raw JSON データ:</h4>
|
|
||||||
<pre className="json-content">
|
|
||||||
{JSON.stringify(boxData, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="box-stats">
|
|
||||||
<p>
|
|
||||||
<strong>総カード数:</strong> {cards.length}枚
|
|
||||||
{selfRecord?.value?.updated_at && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<strong>最終更新:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cards.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="card-grid">
|
|
||||||
{cards.map((card: any, index: number) => (
|
|
||||||
<div key={index} className="box-card-item">
|
|
||||||
<Card
|
|
||||||
card={{
|
|
||||||
id: card.id,
|
|
||||||
cp: card.cp,
|
|
||||||
status: card.status,
|
|
||||||
skill: card.skill,
|
|
||||||
owner_did: card.owner_did,
|
|
||||||
obtained_at: card.obtained_at,
|
|
||||||
is_unique: card.is_unique,
|
|
||||||
unique_id: card.unique_id
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="card-info">
|
|
||||||
<small>ID: {card.id} | CP: {card.cp}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="empty-box">
|
|
||||||
<p>カードボックスにカードがありません</p>
|
|
||||||
<p>カードを引いてからバックアップボタンを押してください</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,113 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Card } from './Card';
|
|
||||||
import { cardApi } from '../services/api';
|
|
||||||
import { Card as CardType } from '../types/card';
|
|
||||||
import '../styles/CardList.css';
|
|
||||||
|
|
||||||
interface CardMasterData {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ja_name: string;
|
|
||||||
description: string;
|
|
||||||
base_cp_min: number;
|
|
||||||
base_cp_max: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CardList: React.FC = () => {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [masterData, setMasterData] = useState<CardMasterData[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMasterData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadMasterData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await fetch('http://localhost:8000/api/v1/cards/master');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch card master data');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setMasterData(data);
|
|
||||||
} catch (err) {
|
|
||||||
// Failed to load card master data
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load card data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="card-list-container">
|
|
||||||
<div className="loading">Loading card data...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="card-list-container">
|
|
||||||
<div className="error">Error: {error}</div>
|
|
||||||
<button onClick={loadMasterData}>Retry</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create cards for all rarity patterns
|
|
||||||
const rarityPatterns = ['normal', 'unique'] as const;
|
|
||||||
|
|
||||||
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
|
|
||||||
|
|
||||||
masterData.forEach(data => {
|
|
||||||
rarityPatterns.forEach(pattern => {
|
|
||||||
const card: CardType = {
|
|
||||||
id: data.id,
|
|
||||||
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
|
|
||||||
status: pattern,
|
|
||||||
skill: null,
|
|
||||||
owner_did: 'sample',
|
|
||||||
obtained_at: new Date().toISOString(),
|
|
||||||
is_unique: pattern === 'unique',
|
|
||||||
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
|
|
||||||
};
|
|
||||||
displayCards.push({
|
|
||||||
card,
|
|
||||||
data,
|
|
||||||
patternName: `${data.id}-${pattern}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card-list-container">
|
|
||||||
<header className="card-list-header">
|
|
||||||
<h1>ai.card マスターリスト</h1>
|
|
||||||
<p>全カード・全レアリティパターン表示</p>
|
|
||||||
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="card-list-simple-grid">
|
|
||||||
{displayCards.map(({ card, data, patternName }) => (
|
|
||||||
<div key={patternName} className="card-list-simple-item">
|
|
||||||
<Card card={card} detailed={false} />
|
|
||||||
<div className="card-info-details">
|
|
||||||
<p><strong>ID:</strong> {data.id}</p>
|
|
||||||
<p><strong>Name:</strong> {data.name}</p>
|
|
||||||
<p><strong>日本語名:</strong> {data.ja_name}</p>
|
|
||||||
<p><strong>レアリティ:</strong> {card.status}</p>
|
|
||||||
<p><strong>CP:</strong> {card.cp}</p>
|
|
||||||
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
|
|
||||||
{data.description && (
|
|
||||||
<p className="card-description">{data.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
129
oauth/src/components/ChatRecordList.jsx
Normal file
129
oauth/src/components/ChatRecordList.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ChatRecordList({ chatPairs, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
|
if (!chatPairs || chatPairs.length === 0) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p>チャット履歴がありません</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (chatPair) => {
|
||||||
|
if (!user || !agent || !chatPair.question?.uri) return
|
||||||
|
|
||||||
|
const confirmed = window.confirm('この会話を削除しますか?')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete question record
|
||||||
|
if (chatPair.question?.uri) {
|
||||||
|
const questionUriParts = chatPair.question.uri.split('/')
|
||||||
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
|
repo: questionUriParts[2],
|
||||||
|
collection: questionUriParts[3],
|
||||||
|
rkey: questionUriParts[4]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete answer record if exists
|
||||||
|
if (chatPair.answer?.uri) {
|
||||||
|
const answerUriParts = chatPair.answer.uri.split('/')
|
||||||
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
|
repo: answerUriParts[2],
|
||||||
|
collection: answerUriParts[3],
|
||||||
|
rkey: answerUriParts[4]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRecordDeleted) {
|
||||||
|
onRecordDeleted()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`削除に失敗しました: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelete = (chatPair) => {
|
||||||
|
return user && agent && chatPair.question?.uri && chatPair.question.value.author?.did === user.did
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{chatPairs.map((chatPair, i) => (
|
||||||
|
<div key={chatPair.rkey} className="chat-conversation">
|
||||||
|
{/* Question */}
|
||||||
|
{chatPair.question && (
|
||||||
|
<div className="chat-message user-message comment-style">
|
||||||
|
<div className="message-header">
|
||||||
|
{chatPair.question.value.author?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={chatPair.question.value.author.avatar}
|
||||||
|
alt={`${chatPair.question.value.author.displayName || chatPair.question.value.author.handle} avatar`}
|
||||||
|
className="avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar">
|
||||||
|
{(chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="display-name">{chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle}</div>
|
||||||
|
</div>
|
||||||
|
{canDelete(chatPair) && (
|
||||||
|
<div className="record-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(chatPair)}
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
title="Delete Conversation"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{chatPair.question.value.text}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Answer */}
|
||||||
|
{chatPair.answer && (
|
||||||
|
<div className="chat-message ai-message comment-style">
|
||||||
|
<div className="message-header">
|
||||||
|
{chatPair.answer.value.author?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={chatPair.answer.value.author.avatar}
|
||||||
|
alt={`${chatPair.answer.value.author.displayName || chatPair.answer.value.author.handle} avatar`}
|
||||||
|
className="avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar">
|
||||||
|
{(chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle || 'AI').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="display-name">{chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{chatPair.answer.value.text}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Post metadata */}
|
||||||
|
{chatPair.question?.value.post?.url && (
|
||||||
|
<div className="record-meta">
|
||||||
|
<a
|
||||||
|
href={chatPair.question.value.post.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="record-url"
|
||||||
|
>
|
||||||
|
{chatPair.question.value.post.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
@@ -1,133 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { aiCardApi } from '../services/api';
|
|
||||||
import '../styles/CollectionAnalysis.css';
|
|
||||||
|
|
||||||
interface AnalysisData {
|
|
||||||
total_cards: number;
|
|
||||||
unique_cards: number;
|
|
||||||
rarity_distribution: Record<string, number>;
|
|
||||||
collection_score: number;
|
|
||||||
recommendations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectionAnalysisProps {
|
|
||||||
userDid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
|
|
||||||
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadAnalysis = async () => {
|
|
||||||
if (!userDid) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await aiCardApi.analyzeCollection(userDid);
|
|
||||||
setAnalysis(result);
|
|
||||||
} catch (err) {
|
|
||||||
// Collection analysis failed
|
|
||||||
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAnalysis();
|
|
||||||
}, [userDid]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="collection-analysis">
|
|
||||||
<div className="analysis-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>AI分析中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="collection-analysis">
|
|
||||||
<div className="analysis-error">
|
|
||||||
<p>{error}</p>
|
|
||||||
<button onClick={loadAnalysis} className="retry-button">
|
|
||||||
再試行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!analysis) {
|
|
||||||
return (
|
|
||||||
<div className="collection-analysis">
|
|
||||||
<div className="analysis-empty">
|
|
||||||
<p>分析データがありません</p>
|
|
||||||
<button onClick={loadAnalysis} className="analyze-button">
|
|
||||||
分析開始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="collection-analysis">
|
|
||||||
<h3>🧠 AI コレクション分析</h3>
|
|
||||||
|
|
||||||
<div className="analysis-stats">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{analysis.total_cards}</div>
|
|
||||||
<div className="stat-label">総カード数</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{analysis.unique_cards}</div>
|
|
||||||
<div className="stat-label">ユニークカード</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{analysis.collection_score}</div>
|
|
||||||
<div className="stat-label">コレクションスコア</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rarity-distribution">
|
|
||||||
<h4>レアリティ分布</h4>
|
|
||||||
<div className="rarity-bars">
|
|
||||||
{Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
|
|
||||||
<div key={rarity} className="rarity-bar">
|
|
||||||
<span className="rarity-name">{rarity}</span>
|
|
||||||
<div className="bar-container">
|
|
||||||
<div
|
|
||||||
className={`bar bar-${rarity.toLowerCase()}`}
|
|
||||||
style={{ width: `${(count / analysis.total_cards) * 100}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span className="rarity-count">{count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis.recommendations && analysis.recommendations.length > 0 && (
|
|
||||||
<div className="recommendations">
|
|
||||||
<h4>🎯 AI推奨</h4>
|
|
||||||
<ul>
|
|
||||||
{analysis.recommendations.map((rec, index) => (
|
|
||||||
<li key={index}>{rec}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={loadAnalysis} className="refresh-analysis">
|
|
||||||
分析更新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -4,19 +4,18 @@ import { env } from '../config/env.js'
|
|||||||
|
|
||||||
export default function CommentForm({ user, agent, onCommentPosted }) {
|
export default function CommentForm({ user, agent, onCommentPosted }) {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!text.trim() || !url.trim()) return
|
if (!text.trim()) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentUrl = url.trim()
|
const currentUrl = window.location.href
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
// Create ai.syui.log record structure (new unified format)
|
// Create ai.syui.log record structure (new unified format)
|
||||||
@@ -30,7 +29,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
|
|||||||
post: {
|
post: {
|
||||||
url: currentUrl,
|
url: currentUrl,
|
||||||
date: timestamp,
|
date: timestamp,
|
||||||
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
|
slug: currentUrl.match(/\/posts\/([^/]+)/)?.[1] || new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
|
||||||
tags: [],
|
tags: [],
|
||||||
title: document.title || 'Comment',
|
title: document.title || 'Comment',
|
||||||
language: 'ja'
|
language: 'ja'
|
||||||
@@ -47,21 +46,31 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post the record
|
// Post the record using the same API as ask-AI
|
||||||
await atproto.putRecord(null, record, agent)
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
|
repo: record.repo,
|
||||||
|
collection: record.collection,
|
||||||
|
rkey: record.rkey,
|
||||||
|
record: record.record
|
||||||
|
})
|
||||||
|
|
||||||
// キャッシュを無効化
|
// キャッシュを無効化
|
||||||
collections.invalidateCache(env.collection)
|
collections.invalidateCache(env.collection)
|
||||||
|
|
||||||
// Clear form
|
// Clear form
|
||||||
setText('')
|
setText('')
|
||||||
setUrl('')
|
|
||||||
|
|
||||||
// Notify parent component
|
// Notify parent component
|
||||||
if (onCommentPosted) {
|
if (onCommentPosted) {
|
||||||
onCommentPosted()
|
onCommentPosted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show success message briefly
|
||||||
|
setText('✓ ')
|
||||||
|
setTimeout(() => {
|
||||||
|
setText('')
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,37 +85,27 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
|
|||||||
padding: '40px',
|
padding: '40px',
|
||||||
color: 'var(--text-secondary)'
|
color: 'var(--text-secondary)'
|
||||||
}}>
|
}}>
|
||||||
<p>ログインしてコメントを投稿</p>
|
<p>atproto login</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>コメントを投稿</h3>
|
<h3>post</h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group" style={{ marginBottom: '12px', padding: '8px', backgroundColor: 'var(--background-secondary)', borderRadius: '4px', fontSize: '0.9em' }}>
|
||||||
<label htmlFor="comment-url">ページURL:</label>
|
<strong>url:</strong> {window.location.href}
|
||||||
<input
|
|
||||||
id="comment-url"
|
|
||||||
type="url"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
placeholder="https://syui.ai/posts/example"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="comment-text">コメント:</label>
|
<label htmlFor="comment-text">comment:</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="comment-text"
|
id="comment-text"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
placeholder="コメントを入力してください..."
|
placeholder="text..."
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -116,17 +115,17 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
エラー: {error}
|
err: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !text.trim() || !url.trim()}
|
disabled={loading || !text.trim()}
|
||||||
className="btn btn-primary"
|
className={`btn ${loading ? 'btn-outline' : 'btn-primary'}`}
|
||||||
>
|
>
|
||||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
{loading ? 'posting...' : 'post'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
@@ -1,130 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Card } from './Card';
|
|
||||||
import { Card as CardType } from '../types/card';
|
|
||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
|
||||||
import '../styles/GachaAnimation.css';
|
|
||||||
|
|
||||||
interface GachaAnimationProps {
|
|
||||||
card: CardType;
|
|
||||||
animationType: string;
|
|
||||||
onComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
|
|
||||||
card,
|
|
||||||
animationType,
|
|
||||||
onComplete
|
|
||||||
}) => {
|
|
||||||
const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
|
|
||||||
const [showCard, setShowCard] = useState(false);
|
|
||||||
const [isSharing, setIsSharing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer1 = setTimeout(() => setPhase('revealing'), 1500);
|
|
||||||
const timer2 = setTimeout(() => {
|
|
||||||
setPhase('complete');
|
|
||||||
setShowCard(true);
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer1);
|
|
||||||
clearTimeout(timer2);
|
|
||||||
};
|
|
||||||
}, [onComplete]);
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
if (showCard) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveToCollection = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isSharing) return;
|
|
||||||
|
|
||||||
setIsSharing(true);
|
|
||||||
try {
|
|
||||||
await atprotoOAuthService.saveCardToCollection(card);
|
|
||||||
alert('カードデータをatprotoコレクションに保存しました!');
|
|
||||||
} catch (error) {
|
|
||||||
// Failed to save card
|
|
||||||
alert('保存に失敗しました。認証が必要かもしれません。');
|
|
||||||
} finally {
|
|
||||||
setIsSharing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEffectClass = () => {
|
|
||||||
switch (animationType) {
|
|
||||||
case 'unique':
|
|
||||||
return 'effect-unique';
|
|
||||||
case 'kira':
|
|
||||||
return 'effect-kira';
|
|
||||||
case 'rare':
|
|
||||||
return 'effect-rare';
|
|
||||||
default:
|
|
||||||
return 'effect-normal';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{phase === 'opening' && (
|
|
||||||
<motion.div
|
|
||||||
key="opening"
|
|
||||||
className="gacha-opening"
|
|
||||||
initial={{ scale: 0, rotate: -180 }}
|
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
|
||||||
exit={{ scale: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.8, type: "spring" }}
|
|
||||||
>
|
|
||||||
<div className="gacha-pack">
|
|
||||||
<div className="pack-glow" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'revealing' && (
|
|
||||||
<motion.div
|
|
||||||
key="revealing"
|
|
||||||
initial={{ scale: 0, rotateY: 180 }}
|
|
||||||
animate={{ scale: 1, rotateY: 0 }}
|
|
||||||
transition={{ duration: 0.8, type: "spring" }}
|
|
||||||
>
|
|
||||||
<Card card={card} isRevealing={true} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'complete' && showCard && (
|
|
||||||
<motion.div
|
|
||||||
key="complete"
|
|
||||||
initial={{ scale: 1, rotateY: 0 }}
|
|
||||||
animate={{ scale: 1, rotateY: 0 }}
|
|
||||||
className="card-final"
|
|
||||||
>
|
|
||||||
<Card card={card} isRevealing={false} />
|
|
||||||
<div className="card-actions">
|
|
||||||
<button
|
|
||||||
className="save-button"
|
|
||||||
onClick={handleSaveToCollection}
|
|
||||||
disabled={isSharing}
|
|
||||||
>
|
|
||||||
{isSharing ? '保存中...' : '💾 atprotoに保存'}
|
|
||||||
</button>
|
|
||||||
<div className="click-hint">クリックして閉じる</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{animationType === 'unique' && (
|
|
||||||
<div className="unique-effect">
|
|
||||||
<div className="unique-particles" />
|
|
||||||
<div className="unique-burst" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,144 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { cardApi, aiCardApi } from '../services/api';
|
|
||||||
import '../styles/GachaStats.css';
|
|
||||||
|
|
||||||
interface GachaStatsData {
|
|
||||||
total_draws: number;
|
|
||||||
cards_by_rarity: Record<string, number>;
|
|
||||||
success_rates: Record<string, number>;
|
|
||||||
recent_activity: Array<{
|
|
||||||
timestamp: string;
|
|
||||||
user_did: string;
|
|
||||||
card_name: string;
|
|
||||||
rarity: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GachaStats: React.FC = () => {
|
|
||||||
const [stats, setStats] = useState<GachaStatsData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [useAI, setUseAI] = useState(true);
|
|
||||||
|
|
||||||
const loadStats = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result;
|
|
||||||
if (useAI) {
|
|
||||||
try {
|
|
||||||
result = await aiCardApi.getEnhancedStats();
|
|
||||||
} catch (aiError) {
|
|
||||||
// AI stats unavailable, using basic stats
|
|
||||||
setUseAI(false);
|
|
||||||
result = await cardApi.getGachaStats();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = await cardApi.getGachaStats();
|
|
||||||
}
|
|
||||||
setStats(result);
|
|
||||||
} catch (err) {
|
|
||||||
// Gacha stats failed
|
|
||||||
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStats();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="gacha-stats">
|
|
||||||
<div className="stats-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>統計データ取得中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="gacha-stats">
|
|
||||||
<div className="stats-error">
|
|
||||||
<p>{error}</p>
|
|
||||||
<button onClick={loadStats} className="retry-button">
|
|
||||||
再試行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats) {
|
|
||||||
return (
|
|
||||||
<div className="gacha-stats">
|
|
||||||
<div className="stats-empty">
|
|
||||||
<p>統計データがありません</p>
|
|
||||||
<button onClick={loadStats} className="load-stats-button">
|
|
||||||
統計取得
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="gacha-stats">
|
|
||||||
<h3>📊 ガチャ統計</h3>
|
|
||||||
|
|
||||||
<div className="stats-overview">
|
|
||||||
<div className="overview-card">
|
|
||||||
<div className="overview-value">{stats.total_draws}</div>
|
|
||||||
<div className="overview-label">総ガチャ実行数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rarity-stats">
|
|
||||||
<h4>レアリティ別出現数</h4>
|
|
||||||
<div className="rarity-grid">
|
|
||||||
{Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
|
|
||||||
<div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
|
|
||||||
<div className="rarity-count">{count}</div>
|
|
||||||
<div className="rarity-name">{rarity}</div>
|
|
||||||
{stats.success_rates[rarity] && (
|
|
||||||
<div className="success-rate">
|
|
||||||
{(stats.success_rates[rarity] * 100).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats.recent_activity && stats.recent_activity.length > 0 && (
|
|
||||||
<div className="recent-activity">
|
|
||||||
<h4>最近の活動</h4>
|
|
||||||
<div className="activity-list">
|
|
||||||
{stats.recent_activity.slice(0, 5).map((activity, index) => (
|
|
||||||
<div key={index} className="activity-item">
|
|
||||||
<div className="activity-time">
|
|
||||||
{new Date(activity.timestamp).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="activity-details">
|
|
||||||
<span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
|
|
||||||
{activity.rarity}
|
|
||||||
</span>
|
|
||||||
<span className="card-name">{activity.card_name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={loadStats} className="refresh-stats">
|
|
||||||
統計更新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,203 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { authService } from '../services/auth';
|
|
||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
|
||||||
import '../styles/Login.css';
|
|
||||||
|
|
||||||
interface LoginProps {
|
|
||||||
onLogin: (did: string, handle: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
defaultHandle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
|
|
||||||
const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
|
|
||||||
const [identifier, setIdentifier] = useState(defaultHandle || '');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prompt for handle if not provided
|
|
||||||
const handle = identifier.trim() || undefined;
|
|
||||||
await atprotoOAuthService.initiateOAuthFlow(handle);
|
|
||||||
// OAuth flow will redirect, so we don't need to handle the response here
|
|
||||||
} catch (err) {
|
|
||||||
setError('OAuth認証の開始に失敗しました。');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLegacyLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authService.login(identifier, password);
|
|
||||||
onLogin(response.did, response.handle);
|
|
||||||
} catch (err) {
|
|
||||||
setError('ログインに失敗しました。認証情報を確認してください。');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="login-overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="login-modal"
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ type: "spring", duration: 0.5 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2>atprotoログイン</h2>
|
|
||||||
|
|
||||||
<div className="login-mode-selector">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
|
|
||||||
onClick={() => setLoginMode('oauth')}
|
|
||||||
>
|
|
||||||
OAuth 2.1 (推奨)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
|
|
||||||
onClick={() => setLoginMode('legacy')}
|
|
||||||
>
|
|
||||||
アプリパスワード
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loginMode === 'oauth' ? (
|
|
||||||
<div className="oauth-login">
|
|
||||||
<div className="oauth-info">
|
|
||||||
<h3>🔐 OAuth 2.1 認証</h3>
|
|
||||||
<p>
|
|
||||||
より安全で標準準拠の認証方式です。
|
|
||||||
ブラウザが一時的にatproto認証サーバーにリダイレクトされます。
|
|
||||||
</p>
|
|
||||||
{(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
|
|
||||||
<div className="dev-notice">
|
|
||||||
<small>🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="oauth-identifier">Bluesky Handle</label>
|
|
||||||
<input
|
|
||||||
id="oauth-identifier"
|
|
||||||
type="text"
|
|
||||||
value={identifier}
|
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
|
||||||
placeholder="your.handle.bsky.social"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="button-group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="oauth-login-button"
|
|
||||||
onClick={handleOAuthLogin}
|
|
||||||
disabled={isLoading || !identifier.trim()}
|
|
||||||
>
|
|
||||||
{isLoading ? '認証開始中...' : 'atprotoで認証'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cancel-button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
キャンセル
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleLegacyLogin}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="identifier">ハンドル または DID</label>
|
|
||||||
<input
|
|
||||||
id="identifier"
|
|
||||||
type="text"
|
|
||||||
value={identifier}
|
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
|
||||||
placeholder="your.handle または did:plc:..."
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="password">アプリパスワード</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="アプリパスワード"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
メインパスワードではなく、
|
|
||||||
<a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
|
|
||||||
アプリパスワード
|
|
||||||
</a>
|
|
||||||
を使用してください
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="button-group">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="login-button"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'ログイン中...' : 'ログイン'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cancel-button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
キャンセル
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="login-info">
|
|
||||||
<p>
|
|
||||||
ai.logはatprotoアカウントを使用します。
|
|
||||||
コメントはあなたのPDSに保存されます。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
36
oauth/src/components/OAuthCallback.jsx
Normal file
36
oauth/src/components/OAuthCallback.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function OAuthCallback() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '50vh',
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
border: '4px solid #f3f3f3',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}} />
|
||||||
|
<h2 style={{ color: '#333', marginBottom: '12px' }}>OAuth認証処理中...</h2>
|
||||||
|
<p style={{ color: '#666', fontSize: '14px' }}>
|
||||||
|
認証が完了しましたら自動で元のページに戻ります
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@@ -1,228 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
|
||||||
|
|
||||||
interface OAuthCallbackProps {
|
|
||||||
onSuccess: (did: string, handle: string) => void;
|
|
||||||
onError: (error: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
|
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(true);
|
|
||||||
const [needsHandle, setNeedsHandle] = useState(false);
|
|
||||||
const [handle, setHandle] = useState('');
|
|
||||||
const [tempSession, setTempSession] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Add timeout to prevent infinite loading
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
onError('OAuth認証がタイムアウトしました');
|
|
||||||
}, 10000); // 10 second timeout
|
|
||||||
|
|
||||||
const handleCallback = async () => {
|
|
||||||
try {
|
|
||||||
// Handle both query params (?) and hash params (#)
|
|
||||||
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
|
||||||
const queryParams = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
// Try hash first (Bluesky uses this), then fallback to query
|
|
||||||
const code = hashParams.get('code') || queryParams.get('code');
|
|
||||||
const state = hashParams.get('state') || queryParams.get('state');
|
|
||||||
const error = hashParams.get('error') || queryParams.get('error');
|
|
||||||
const iss = hashParams.get('iss') || queryParams.get('iss');
|
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(`OAuth error: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code || !state) {
|
|
||||||
throw new Error('Missing OAuth parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Use the official BrowserOAuthClient to handle the callback
|
|
||||||
const result = await atprotoOAuthService.handleOAuthCallback();
|
|
||||||
if (result) {
|
|
||||||
|
|
||||||
// Success - notify parent component
|
|
||||||
onSuccess(result.did, result.handle);
|
|
||||||
} else {
|
|
||||||
throw new Error('OAuth callback did not return a session');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Even if OAuth fails, try to continue with a fallback approach
|
|
||||||
try {
|
|
||||||
// Create a minimal session to allow the user to proceed
|
|
||||||
const fallbackSession = {
|
|
||||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
|
||||||
handle: 'syui.ai'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notify success with fallback session
|
|
||||||
onSuccess(fallbackSession.did, fallbackSession.handle);
|
|
||||||
|
|
||||||
} catch (fallbackError) {
|
|
||||||
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId); // Clear timeout on completion
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCallback();
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}, [onSuccess, onError]);
|
|
||||||
|
|
||||||
const handleSubmitHandle = async (e?: React.FormEvent) => {
|
|
||||||
if (e) e.preventDefault();
|
|
||||||
|
|
||||||
const trimmedHandle = handle.trim();
|
|
||||||
if (!trimmedHandle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Resolve DID from handle
|
|
||||||
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
|
|
||||||
|
|
||||||
// Update session with resolved DID and handle
|
|
||||||
const updatedSession = {
|
|
||||||
...tempSession,
|
|
||||||
did: did,
|
|
||||||
handle: trimmedHandle
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save updated session
|
|
||||||
atprotoOAuthService.saveSessionToStorage(updatedSession);
|
|
||||||
|
|
||||||
// Success - notify parent component
|
|
||||||
onSuccess(did, trimmedHandle);
|
|
||||||
} catch (error) {
|
|
||||||
setIsProcessing(false);
|
|
||||||
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (needsHandle) {
|
|
||||||
return (
|
|
||||||
<div className="oauth-callback">
|
|
||||||
<div className="oauth-processing">
|
|
||||||
<h2>Blueskyハンドルを入力してください</h2>
|
|
||||||
<p>OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。</p>
|
|
||||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
|
|
||||||
入力中: {handle || '(未入力)'} | 文字数: {handle.length}
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmitHandle}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={handle}
|
|
||||||
onChange={(e) => {
|
|
||||||
setHandle(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="例: syui.ai または user.bsky.social"
|
|
||||||
autoFocus
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px',
|
|
||||||
marginTop: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
fontSize: '16px',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!handle.trim() || isProcessing}
|
|
||||||
style={{
|
|
||||||
padding: '12px 24px',
|
|
||||||
backgroundColor: handle.trim() ? '#667eea' : '#444',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
cursor: handle.trim() ? 'pointer' : 'not-allowed',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
width: '100%'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isProcessing ? '処理中...' : '続行'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
return (
|
|
||||||
<div className="oauth-callback">
|
|
||||||
<div className="oauth-processing">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// CSS styles (inline for simplicity)
|
|
||||||
const styles = `
|
|
||||||
.oauth-callback {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
|
|
||||||
color: #333;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-processing {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
border-radius: 16px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-top: 3px solid #1185fe;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Inject styles
|
|
||||||
const styleSheet = document.createElement('style');
|
|
||||||
styleSheet.type = 'text/css';
|
|
||||||
styleSheet.innerText = styles;
|
|
||||||
document.head.appendChild(styleSheet);
|
|
@@ -1,36 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { OAuthCallback } from './OAuthCallback';
|
|
||||||
|
|
||||||
export const OAuthCallbackPage: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSuccess = (did: string, handle: string) => {
|
|
||||||
|
|
||||||
// Add a small delay to ensure state is properly updated
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: string) => {
|
|
||||||
|
|
||||||
// Add a small delay before redirect
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}, 2000); // Give user time to see error
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Processing OAuth callback...</h2>
|
|
||||||
<OAuthCallback
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onError={handleError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
165
oauth/src/components/ProfileForm.jsx
Normal file
165
oauth/src/components/ProfileForm.jsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { atproto, collections } from '../api/atproto.js'
|
||||||
|
import { env } from '../config/env.js'
|
||||||
|
|
||||||
|
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [type, setType] = useState('user')
|
||||||
|
const [handle, setHandle] = useState('')
|
||||||
|
const [rkey, setRkey] = useState('')
|
||||||
|
const [posting, setPosting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!text.trim() || !handle.trim() || !rkey.trim()) {
|
||||||
|
setError('すべてのフィールドを入力してください')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get handle information
|
||||||
|
let authorData
|
||||||
|
try {
|
||||||
|
const handleDid = await atproto.getDid(apiConfig.pds, handle)
|
||||||
|
// Use agent to get profile with authentication
|
||||||
|
const profileResponse = await agent.api.app.bsky.actor.getProfile({ actor: handleDid })
|
||||||
|
authorData = profileResponse.data
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('ハンドルが見つかりません')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create record using the same pattern as CommentForm
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const record = {
|
||||||
|
repo: user.did,
|
||||||
|
collection: env.collection,
|
||||||
|
rkey: rkey,
|
||||||
|
record: {
|
||||||
|
$type: env.collection,
|
||||||
|
text: text,
|
||||||
|
type: 'profile',
|
||||||
|
profileType: type, // admin or user
|
||||||
|
author: {
|
||||||
|
did: authorData.did,
|
||||||
|
handle: authorData.handle,
|
||||||
|
displayName: authorData.displayName || authorData.handle,
|
||||||
|
avatar: authorData.avatar || null
|
||||||
|
},
|
||||||
|
createdAt: timestamp,
|
||||||
|
post: {
|
||||||
|
url: window.location.origin,
|
||||||
|
date: timestamp,
|
||||||
|
slug: '',
|
||||||
|
tags: [],
|
||||||
|
title: 'Profile',
|
||||||
|
language: 'ja'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post the record using agent like CommentForm
|
||||||
|
await agent.api.com.atproto.repo.putRecord(record)
|
||||||
|
|
||||||
|
// Invalidate cache and refresh
|
||||||
|
collections.invalidateCache(env.collection)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setText('')
|
||||||
|
setType('user')
|
||||||
|
setHandle('')
|
||||||
|
setRkey('')
|
||||||
|
|
||||||
|
if (onProfilePosted) {
|
||||||
|
onProfilePosted()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create profile:', err)
|
||||||
|
setError(err.message || 'プロフィールの作成に失敗しました')
|
||||||
|
} finally {
|
||||||
|
setPosting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-form-container">
|
||||||
|
<h3>プロフィール投稿</h3>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="profile-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="handle">ハンドル</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="handle"
|
||||||
|
value={handle}
|
||||||
|
onChange={(e) => setHandle(e.target.value)}
|
||||||
|
placeholder="例: syui.ai"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="rkey">Rkey</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="rkey"
|
||||||
|
value={rkey}
|
||||||
|
onChange={(e) => setRkey(e.target.value)}
|
||||||
|
placeholder="例: syui"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="type">タイプ</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="text">プロフィールテキスト</label>
|
||||||
|
<textarea
|
||||||
|
id="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="プロフィールの説明を入力してください"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={posting || !text.trim() || !handle.trim() || !rkey.trim()}
|
||||||
|
className="submit-btn"
|
||||||
|
>
|
||||||
|
{posting ? '投稿中...' : '投稿'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileForm
|
83
oauth/src/components/ProfileRecordList.jsx
Normal file
83
oauth/src/components/ProfileRecordList.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ProfileRecordList({ profileRecords, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
|
if (!profileRecords || profileRecords.length === 0) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p>プロフィールがありません</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (profile) => {
|
||||||
|
if (!user || !agent || !profile.uri) return
|
||||||
|
|
||||||
|
const confirmed = window.confirm('このプロフィールを削除しますか?')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uriParts = profile.uri.split('/')
|
||||||
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
|
repo: uriParts[2],
|
||||||
|
collection: uriParts[3],
|
||||||
|
rkey: uriParts[4]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (onRecordDeleted) {
|
||||||
|
onRecordDeleted()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`削除に失敗しました: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelete = (profile) => {
|
||||||
|
if (!user || !agent || !profile.uri) return false
|
||||||
|
|
||||||
|
// Check if the record is in the current user's repository
|
||||||
|
const recordRepoDid = profile.uri.split('/')[2]
|
||||||
|
return recordRepoDid === user.did
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{profileRecords.map((profile) => (
|
||||||
|
<div key={profile.uri} className="chat-message comment-style">
|
||||||
|
<div className="message-header">
|
||||||
|
{profile.value.author?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={profile.value.author.avatar}
|
||||||
|
alt={`${profile.value.author.displayName || profile.value.author.handle} avatar`}
|
||||||
|
className="avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar">
|
||||||
|
{(profile.value.author?.displayName || profile.value.author?.handle || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="display-name">
|
||||||
|
{profile.value.author?.displayName || profile.value.author?.handle}
|
||||||
|
{profile.value.profileType === 'admin' && (
|
||||||
|
<span className="admin-badge"> Admin</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canDelete(profile) && (
|
||||||
|
<div className="record-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(profile)}
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
title="Delete Profile"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{profile.value.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
@@ -2,6 +2,24 @@ import React, { useState } from 'react'
|
|||||||
import AvatarImage from './AvatarImage.jsx'
|
import AvatarImage from './AvatarImage.jsx'
|
||||||
import Avatar from './Avatar.jsx'
|
import Avatar from './Avatar.jsx'
|
||||||
|
|
||||||
|
// Helper function to get correct web URL based on avatar URL
|
||||||
|
function getCorrectWebUrl(avatarUrl) {
|
||||||
|
if (!avatarUrl) return 'https://bsky.app'
|
||||||
|
|
||||||
|
// If avatar is from bsky.app (main Bluesky), use bsky.app
|
||||||
|
if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
|
||||||
|
return 'https://bsky.app'
|
||||||
|
}
|
||||||
|
|
||||||
|
// If avatar is from syu.is, use web.syu.is
|
||||||
|
if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
|
||||||
|
return 'https://syu.is'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to bsky.app
|
||||||
|
return 'https://bsky.app'
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
|
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
||||||
const [deletingRecords, setDeletingRecords] = useState(new Set())
|
const [deletingRecords, setDeletingRecords] = useState(new Set())
|
||||||
@@ -74,7 +92,7 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
|
|||||||
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
|
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
|
||||||
<div className="handle">
|
<div className="handle">
|
||||||
<a
|
<a
|
||||||
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`}
|
href={`${getCorrectWebUrl(record.value.author?.avatar)}/profile/${record.value.author?.did}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="handle-link"
|
className="handle-link"
|
||||||
@@ -107,17 +125,6 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedRecords.has(i) && (
|
|
||||||
<div className="json-display">
|
|
||||||
<div className="json-header">json data</div>
|
|
||||||
<pre className="json-content">
|
|
||||||
{JSON.stringify(record, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="record-content">{record.value.text || record.value.content}</div>
|
|
||||||
|
|
||||||
<div className="record-meta">
|
<div className="record-meta">
|
||||||
{record.value.post?.url && (
|
{record.value.post?.url && (
|
||||||
<a
|
<a
|
||||||
@@ -130,6 +137,16 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expandedRecords.has(i) && (
|
||||||
|
<div className="json-display">
|
||||||
|
<pre className="json-content">
|
||||||
|
{JSON.stringify(record, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="record-content">{record.value.text || record.value.content}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
@@ -1,9 +1,14 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import RecordList from './RecordList.jsx'
|
import RecordList from './RecordList.jsx'
|
||||||
|
import ChatRecordList from './ChatRecordList.jsx'
|
||||||
|
import ProfileRecordList from './ProfileRecordList.jsx'
|
||||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||||
|
import { logger } from '../utils/logger.js'
|
||||||
|
|
||||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
const [activeTab, setActiveTab] = useState('lang')
|
const [activeTab, setActiveTab] = useState('profiles')
|
||||||
|
|
||||||
|
logger.log('RecordTabs: activeTab is', activeTab)
|
||||||
|
|
||||||
// Filter records based on page context
|
// Filter records based on page context
|
||||||
const filterRecords = (records) => {
|
const filterRecords = (records) => {
|
||||||
@@ -32,32 +37,50 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
const filteredChatRecords = filterRecords(chatRecords || [])
|
const filteredChatRecords = filterRecords(chatRecords || [])
|
||||||
const filteredBaseRecords = filterRecords(baseRecords || [])
|
const filteredBaseRecords = filterRecords(baseRecords || [])
|
||||||
|
|
||||||
|
// Filter profile records from baseRecords
|
||||||
|
const profileRecords = (baseRecords || []).filter(record => record.value?.type === 'profile')
|
||||||
|
const sortedProfileRecords = profileRecords.sort((a, b) => {
|
||||||
|
if (a.value.profileType === 'admin' && b.value.profileType !== 'admin') return -1
|
||||||
|
if (a.value.profileType !== 'admin' && b.value.profileType === 'admin') return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
const filteredProfileRecords = filterRecords(sortedProfileRecords)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="record-tabs">
|
<div className="record-tabs">
|
||||||
<div className="tab-header">
|
<div className="tab-header">
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('lang')}
|
onClick={() => {
|
||||||
|
logger.log('RecordTabs: Profiles tab clicked')
|
||||||
|
setActiveTab('profiles')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Lang ({filteredLangRecords.length})
|
about ({filteredProfileRecords.length})
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('comment')}
|
|
||||||
>
|
|
||||||
Comment ({filteredCommentRecords.length})
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('collection')}
|
onClick={() => setActiveTab('collection')}
|
||||||
>
|
>
|
||||||
Posts ({filteredBaseRecords.length})
|
chat ({userChatRecords?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('comment')}
|
||||||
|
>
|
||||||
|
feedback ({filteredCommentRecords.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('users')}
|
onClick={() => setActiveTab('users')}
|
||||||
>
|
>
|
||||||
Users ({filteredUserComments.length})
|
comment ({filteredUserComments.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('lang')}
|
||||||
|
>
|
||||||
|
en ({filteredLangRecords.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,17 +116,15 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{activeTab === 'collection' && (
|
{activeTab === 'collection' && (
|
||||||
!baseRecords ? (
|
userChatLoading ? (
|
||||||
<LoadingSkeleton count={2} showTitle={true} />
|
<LoadingSkeleton count={2} showTitle={true} />
|
||||||
) : (
|
) : (
|
||||||
<RecordList
|
<ChatRecordList
|
||||||
title=""
|
chatPairs={userChatRecords}
|
||||||
records={filteredBaseRecords}
|
|
||||||
apiConfig={apiConfig}
|
apiConfig={apiConfig}
|
||||||
user={user}
|
user={user}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
onRecordDeleted={onRecordDeleted}
|
onRecordDeleted={onRecordDeleted}
|
||||||
showTitle={false}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -122,6 +143,19 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'profiles' && (
|
||||||
|
!baseRecords ? (
|
||||||
|
<LoadingSkeleton count={3} showTitle={true} />
|
||||||
|
) : (
|
||||||
|
<ProfileRecordList
|
||||||
|
profileRecords={filteredProfileRecords}
|
||||||
|
apiConfig={apiConfig}
|
||||||
|
user={user}
|
||||||
|
agent={agent}
|
||||||
|
onRecordDeleted={onRecordDeleted}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
@@ -1,158 +0,0 @@
|
|||||||
// Application configuration
|
|
||||||
export interface AppConfig {
|
|
||||||
adminDid: string;
|
|
||||||
adminHandle: string;
|
|
||||||
aiDid: string;
|
|
||||||
aiHandle: string;
|
|
||||||
aiDisplayName: string;
|
|
||||||
aiAvatar: string;
|
|
||||||
aiDescription: string;
|
|
||||||
collections: {
|
|
||||||
base: string; // Base collection like "ai.syui.log"
|
|
||||||
};
|
|
||||||
host: string;
|
|
||||||
rkey?: string; // Current post rkey if on post page
|
|
||||||
aiEnabled: boolean;
|
|
||||||
aiAskAi: boolean;
|
|
||||||
aiProvider: string;
|
|
||||||
aiModel: string;
|
|
||||||
aiHost: string;
|
|
||||||
aiSystemPrompt: string;
|
|
||||||
allowedHandles: string[]; // Handles allowed for OAuth authentication
|
|
||||||
atprotoPds: string; // Configured PDS for admin/ai handles
|
|
||||||
// Legacy - prefer per-user PDS detection
|
|
||||||
bskyPublicApi: string;
|
|
||||||
atprotoApi: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection name builders (similar to Rust implementation)
|
|
||||||
export function getCollectionNames(base: string) {
|
|
||||||
if (!base) {
|
|
||||||
// Fallback to default
|
|
||||||
base = 'ai.syui.log';
|
|
||||||
}
|
|
||||||
|
|
||||||
const collections = {
|
|
||||||
comment: base,
|
|
||||||
user: `${base}.user`,
|
|
||||||
chat: `${base}.chat`,
|
|
||||||
chatLang: `${base}.chat.lang`,
|
|
||||||
chatComment: `${base}.chat.comment`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return collections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate collection names from host
|
|
||||||
// Format: ${reg}.${name}.${sub}
|
|
||||||
// Example: log.syui.ai -> ai.syui.log
|
|
||||||
function generateBaseCollectionFromHost(host: string): string {
|
|
||||||
try {
|
|
||||||
// Remove protocol if present
|
|
||||||
const cleanHost = host.replace(/^https?:\/\//, '');
|
|
||||||
|
|
||||||
// Split host into parts
|
|
||||||
const parts = cleanHost.split('.');
|
|
||||||
|
|
||||||
if (parts.length < 2) {
|
|
||||||
throw new Error('Invalid host format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse the parts for collection naming
|
|
||||||
// log.syui.ai -> ai.syui.log
|
|
||||||
const reversedParts = parts.reverse();
|
|
||||||
const result = reversedParts.join('.');
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback to default
|
|
||||||
return 'ai.syui.log';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract rkey from current URL
|
|
||||||
// /posts/xxx -> xxx (remove .html if present)
|
|
||||||
function extractRkeyFromUrl(): string | undefined {
|
|
||||||
const pathname = window.location.pathname;
|
|
||||||
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
|
|
||||||
if (match) {
|
|
||||||
// Remove .html extension if present
|
|
||||||
return match[1].replace(/\.html$/, '');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get application configuration from environment variables
|
|
||||||
export function getAppConfig(): AppConfig {
|
|
||||||
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
|
|
||||||
const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai';
|
|
||||||
const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai';
|
|
||||||
|
|
||||||
// DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
|
|
||||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
|
||||||
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
|
|
||||||
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
|
|
||||||
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
|
|
||||||
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
|
|
||||||
|
|
||||||
// Priority: Environment variables > Auto-generated from host
|
|
||||||
const autoGeneratedBase = generateBaseCollectionFromHost(host);
|
|
||||||
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
|
|
||||||
|
|
||||||
// Ensure base collection is never undefined
|
|
||||||
if (!baseCollection) {
|
|
||||||
baseCollection = 'ai.syui.log';
|
|
||||||
}
|
|
||||||
|
|
||||||
const collections = {
|
|
||||||
base: baseCollection,
|
|
||||||
};
|
|
||||||
|
|
||||||
const rkey = extractRkeyFromUrl();
|
|
||||||
|
|
||||||
// AI configuration
|
|
||||||
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
|
|
||||||
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
|
|
||||||
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
|
|
||||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
|
|
||||||
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
|
|
||||||
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
|
|
||||||
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
|
||||||
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
|
|
||||||
const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social';
|
|
||||||
|
|
||||||
// Parse allowed handles list
|
|
||||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
|
||||||
let allowedHandles: string[] = [];
|
|
||||||
try {
|
|
||||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
|
||||||
} catch {
|
|
||||||
// If parsing fails, allow all handles (empty array means no restriction)
|
|
||||||
allowedHandles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adminDid,
|
|
||||||
adminHandle,
|
|
||||||
aiDid,
|
|
||||||
aiHandle,
|
|
||||||
aiDisplayName,
|
|
||||||
aiAvatar,
|
|
||||||
aiDescription,
|
|
||||||
collections,
|
|
||||||
host,
|
|
||||||
rkey,
|
|
||||||
aiEnabled,
|
|
||||||
aiAskAi,
|
|
||||||
aiProvider,
|
|
||||||
aiModel,
|
|
||||||
aiHost,
|
|
||||||
aiSystemPrompt,
|
|
||||||
allowedHandles,
|
|
||||||
atprotoPds,
|
|
||||||
bskyPublicApi,
|
|
||||||
atprotoApi
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const appConfig = getAppConfig();
|
|
@@ -3,14 +3,15 @@ import { atproto, collections } from '../api/atproto.js'
|
|||||||
import { env } from '../config/env.js'
|
import { env } from '../config/env.js'
|
||||||
import { logger } from '../utils/logger.js'
|
import { logger } from '../utils/logger.js'
|
||||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
||||||
|
import { AIProviderFactory } from '../services/aiProvider.js'
|
||||||
|
|
||||||
export function useAskAI(adminData, userProfile, agent) {
|
export function useAskAI(adminData, userProfile, agent) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [chatHistory, setChatHistory] = useState([])
|
const [chatHistory, setChatHistory] = useState([])
|
||||||
|
|
||||||
// ask-AIサーバーのURL(環境変数から取得、フォールバック付き)
|
// AIプロバイダーを環境変数から作成
|
||||||
const askAIUrl = import.meta.env.VITE_ASK_AI_URL || 'http://localhost:3000/ask'
|
const aiProvider = AIProviderFactory.createFromEnv()
|
||||||
|
|
||||||
const askQuestion = async (question) => {
|
const askQuestion = async (question) => {
|
||||||
if (!question.trim()) return
|
if (!question.trim()) return
|
||||||
@@ -19,28 +20,13 @@ export function useAskAI(adminData, userProfile, agent) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log('Sending question to ask-AI:', question)
|
logger.log('Sending question to AI provider:', question)
|
||||||
|
|
||||||
// ask-AIサーバーにリクエスト送信
|
// AIプロバイダーに質問を送信
|
||||||
const response = await fetch(askAIUrl, {
|
const aiResponse = await aiProvider.ask(question, {
|
||||||
method: 'POST',
|
userProfile: userProfile
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
question: question.trim(),
|
|
||||||
context: {
|
|
||||||
url: window.location.href,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`ask-AI server error: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiResponse = await response.json()
|
|
||||||
logger.log('Received AI response:', aiResponse)
|
logger.log('Received AI response:', aiResponse)
|
||||||
|
|
||||||
// AI回答をチャット履歴に追加
|
// AI回答をチャット履歴に追加
|
||||||
@@ -81,7 +67,17 @@ export function useAskAI(adminData, userProfile, agent) {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, 'useAskAI.askQuestion')
|
logError(err, 'useAskAI.askQuestion')
|
||||||
setError(getErrorMessage(err))
|
|
||||||
|
let errorMessage = 'AI応答の生成に失敗しました'
|
||||||
|
if (err.message.includes('Request timeout')) {
|
||||||
|
errorMessage = 'AI応答がタイムアウトしました'
|
||||||
|
} else if (err.message.includes('API error')) {
|
||||||
|
errorMessage = `API エラー: ${err.message}`
|
||||||
|
} else if (err.message.includes('Failed to fetch')) {
|
||||||
|
errorMessage = 'AI サーバーに接続できませんでした'
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage)
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
80
oauth/src/hooks/useAuth.js
Normal file
80
oauth/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { OAuthService } from '../services/oauth.js'
|
||||||
|
|
||||||
|
const oauthService = new OAuthService()
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [agent, setAgent] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initAuth = async () => {
|
||||||
|
try {
|
||||||
|
const authResult = await oauthService.checkAuth()
|
||||||
|
if (authResult) {
|
||||||
|
setUser(authResult.user)
|
||||||
|
setAgent(authResult.agent)
|
||||||
|
|
||||||
|
// If we're on callback page and authentication succeeded, notify parent
|
||||||
|
if (window.location.pathname === '/oauth/callback') {
|
||||||
|
console.log('OAuth callback completed, notifying parent window')
|
||||||
|
|
||||||
|
// Get referrer or use stored return URL
|
||||||
|
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
|
||||||
|
document.referrer ||
|
||||||
|
window.location.origin
|
||||||
|
|
||||||
|
sessionStorage.removeItem('oauth_return_url')
|
||||||
|
|
||||||
|
// Notify parent window if in iframe, otherwise redirect directly
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'oauth_success',
|
||||||
|
returnUrl: returnUrl,
|
||||||
|
user: authResult.user
|
||||||
|
}, '*')
|
||||||
|
} else {
|
||||||
|
// Set flag to skip loading screen after redirect
|
||||||
|
sessionStorage.setItem('oauth_just_completed', 'true')
|
||||||
|
// Direct redirect
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = returnUrl
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth initialization failed:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (handle) => {
|
||||||
|
// Store current page URL for post-auth redirect
|
||||||
|
if (window.location.pathname !== '/oauth/callback') {
|
||||||
|
sessionStorage.setItem('oauth_return_url', window.location.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
await oauthService.login(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await oauthService.logout()
|
||||||
|
setUser(null)
|
||||||
|
setAgent(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
agent,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!user
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { atproto, collections } from '../api/atproto.js'
|
import { atproto, collections } from '../api/atproto.js'
|
||||||
import { getApiConfig, isSyuIsHandle } from '../utils/pds.js'
|
import { getApiConfig, isSyuIsHandle, getPdsFromHandle } from '../utils/pds.js'
|
||||||
import { env } from '../config/env.js'
|
import { env } from '../config/env.js'
|
||||||
|
|
||||||
export function useUserData(adminData) {
|
export function useUserData(adminData) {
|
||||||
@@ -88,14 +88,21 @@ export function useUserData(adminData) {
|
|||||||
userPds = user.pds.replace('https://', '')
|
userPds = user.pds.replace('https://', '')
|
||||||
userApiConfig = getApiConfig(userPds)
|
userApiConfig = getApiConfig(userPds)
|
||||||
} else {
|
} else {
|
||||||
// Auto-detect PDS based on handle and get real DID
|
// Always get actual PDS from describeRepo first
|
||||||
if (isSyuIsHandle(userHandle)) {
|
try {
|
||||||
|
// Try bsky.social first for most handles
|
||||||
|
const bskyPds = 'bsky.social'
|
||||||
|
userDid = await atproto.getDid(bskyPds, userHandle)
|
||||||
|
|
||||||
|
// Get the actual PDS endpoint from DID
|
||||||
|
const realPds = await getPdsFromHandle(userHandle)
|
||||||
|
userPds = realPds.replace('https://', '')
|
||||||
|
userApiConfig = getApiConfig(realPds)
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to syu.is if bsky.social fails
|
||||||
|
console.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
|
||||||
userPds = env.pds
|
userPds = env.pds
|
||||||
userApiConfig = getApiConfig(userPds)
|
userApiConfig = getApiConfig(env.pds)
|
||||||
userDid = await atproto.getDid(userPds, userHandle)
|
|
||||||
} else {
|
|
||||||
userPds = 'bsky.social'
|
|
||||||
userApiConfig = getApiConfig(userPds)
|
|
||||||
userDid = await atproto.getDid(userPds, userHandle)
|
userDid = await atproto.getDid(userPds, userHandle)
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,28 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
|
||||||
import App from './App'
|
|
||||||
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
|
|
||||||
import { CardList } from './components/CardList'
|
|
||||||
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
|
|
||||||
|
|
||||||
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
|
|
||||||
// DISABLED: This may interfere with BrowserOAuthClient
|
|
||||||
// OAuthEndpointHandler.init()
|
|
||||||
|
|
||||||
// Mount React app to all comment-atproto divs
|
|
||||||
const mountPoints = document.querySelectorAll('#comment-atproto');
|
|
||||||
|
|
||||||
mountPoints.forEach((mountPoint, index) => {
|
|
||||||
ReactDOM.createRoot(mountPoint as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
|
||||||
<Route path="/list" element={<CardList />} />
|
|
||||||
<Route path="*" element={<App />} />
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
});
|
|
214
oauth/src/services/aiProvider.js
Normal file
214
oauth/src/services/aiProvider.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* AI Provider Abstract Interface
|
||||||
|
* Supports multiple AI backends (Ollama, Claude, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AIProvider {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a question to the AI and get a response
|
||||||
|
* @param {string} question - User's question
|
||||||
|
* @param {Object} context - Additional context (user info, etc.)
|
||||||
|
* @returns {Promise<{answer: string}>}
|
||||||
|
*/
|
||||||
|
async ask(question, context = {}) {
|
||||||
|
throw new Error('ask() method must be implemented by subclass')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider is available
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async healthCheck() {
|
||||||
|
throw new Error('healthCheck() method must be implemented by subclass')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama Provider Implementation
|
||||||
|
*/
|
||||||
|
export class OllamaProvider extends AIProvider {
|
||||||
|
constructor(config) {
|
||||||
|
super(config)
|
||||||
|
this.host = config.host || 'https://ollama.syui.ai'
|
||||||
|
this.model = config.model || 'gemma3:1b'
|
||||||
|
this.systemPrompt = config.systemPrompt || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async ask(question, context = {}) {
|
||||||
|
// Build enhanced prompt with user context
|
||||||
|
const userInfo = context.userProfile
|
||||||
|
? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
|
||||||
|
: ''
|
||||||
|
const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
|
||||||
|
|
||||||
|
const prompt = `${enhancedSystemPrompt}
|
||||||
|
|
||||||
|
Question: ${question}
|
||||||
|
|
||||||
|
Answer:`
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.host}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'https://syui.ai',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
prompt: prompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.9,
|
||||||
|
top_p: 0.9,
|
||||||
|
num_predict: 200,
|
||||||
|
repeat_penalty: 1.1,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { answer: data.response || 'エラーが発生しました' }
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timeout')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.host}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Origin': 'https://syui.ai',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude MCP Server Provider Implementation
|
||||||
|
*/
|
||||||
|
export class ClaudeMCPProvider extends AIProvider {
|
||||||
|
constructor(config) {
|
||||||
|
super(config)
|
||||||
|
this.endpoint = config.endpoint || 'https://your-server.com/api/claude-mcp'
|
||||||
|
this.apiKey = config.apiKey // Server-side auth token
|
||||||
|
this.systemPrompt = config.systemPrompt || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async ask(question, context = {}) {
|
||||||
|
const userInfo = context.userProfile
|
||||||
|
? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
|
||||||
|
: ''
|
||||||
|
const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 45000) // Longer timeout for Claude
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question: question,
|
||||||
|
systemPrompt: enhancedSystemPrompt,
|
||||||
|
context: context
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Claude MCP error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { answer: data.answer || 'エラーが発生しました' }
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timeout')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.endpoint}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Provider Factory
|
||||||
|
*/
|
||||||
|
export class AIProviderFactory {
|
||||||
|
static create(provider, config) {
|
||||||
|
switch (provider) {
|
||||||
|
case 'ollama':
|
||||||
|
return new OllamaProvider(config)
|
||||||
|
case 'claude-mcp':
|
||||||
|
return new ClaudeMCPProvider(config)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown AI provider: ${provider}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static createFromEnv() {
|
||||||
|
const provider = import.meta.env.VITE_AI_PROVIDER || 'ollama'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'ollama':
|
||||||
|
config.host = import.meta.env.VITE_AI_HOST
|
||||||
|
config.model = import.meta.env.VITE_AI_MODEL
|
||||||
|
break
|
||||||
|
case 'claude-mcp':
|
||||||
|
config.endpoint = import.meta.env.VITE_CLAUDE_MCP_ENDPOINT
|
||||||
|
config.apiKey = import.meta.env.VITE_CLAUDE_MCP_API_KEY
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return AIProviderFactory.create(provider, config)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,105 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { CardDrawResult } from '../types/card';
|
|
||||||
|
|
||||||
// ai.card 直接APIアクセス(メイン)
|
|
||||||
const API_HOST = import.meta.env.VITE_API_HOST || '';
|
|
||||||
const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
|
|
||||||
|
|
||||||
// ai.gpt MCP統合(オプション機能)
|
|
||||||
const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'
|
|
||||||
? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const cardApi_internal = axios.create({
|
|
||||||
baseURL: API_BASE,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const aiGptApi = AI_GPT_BASE ? axios.create({
|
|
||||||
baseURL: AI_GPT_BASE,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}) : null;
|
|
||||||
|
|
||||||
// ai.cardの直接API(基本機能)
|
|
||||||
export const cardApi = {
|
|
||||||
drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => {
|
|
||||||
const response = await cardApi_internal.post('/cards/draw', {
|
|
||||||
user_did: userDid,
|
|
||||||
is_paid: isPaid,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserCards: async (userDid: string) => {
|
|
||||||
const response = await cardApi_internal.get(`/cards/user/${userDid}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getCardDetails: async (cardId: number) => {
|
|
||||||
const response = await cardApi_internal.get(`/cards/${cardId}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getUniqueCards: async () => {
|
|
||||||
const response = await cardApi_internal.get('/cards/unique');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getGachaStats: async () => {
|
|
||||||
const response = await cardApi_internal.get('/cards/stats');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// システム状態確認
|
|
||||||
getSystemStatus: async () => {
|
|
||||||
const response = await cardApi_internal.get('/health');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ai.gpt統合API(オプション機能 - AI拡張)
|
|
||||||
export const aiCardApi = {
|
|
||||||
analyzeCollection: async (userDid: string) => {
|
|
||||||
if (!aiGptApi) {
|
|
||||||
throw new Error('AI機能が無効化されています');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await aiGptApi.get('/card_analyze_collection', {
|
|
||||||
params: { did: userDid }
|
|
||||||
});
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getEnhancedStats: async () => {
|
|
||||||
if (!aiGptApi) {
|
|
||||||
throw new Error('AI機能が無効化されています');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await aiGptApi.get('/card_get_gacha_stats');
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// AI機能が利用可能かチェック
|
|
||||||
isAIAvailable: async (): Promise<boolean> => {
|
|
||||||
if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await aiGptApi.get('/health');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
@@ -1,571 +0,0 @@
|
|||||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
|
|
||||||
import { Agent } from '@atproto/api';
|
|
||||||
|
|
||||||
interface AtprotoSession {
|
|
||||||
did: string;
|
|
||||||
handle: string;
|
|
||||||
accessJwt: string;
|
|
||||||
refreshJwt: string;
|
|
||||||
email?: string;
|
|
||||||
emailConfirmed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AtprotoOAuthService {
|
|
||||||
private oauthClient: BrowserOAuthClient | null = null;
|
|
||||||
private oauthClientSyuIs: BrowserOAuthClient | null = null;
|
|
||||||
private agent: Agent | null = null;
|
|
||||||
private initializePromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Don't initialize immediately, wait for first use
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initialize(): Promise<void> {
|
|
||||||
// Prevent multiple initializations
|
|
||||||
if (this.initializePromise) {
|
|
||||||
return this.initializePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initializePromise = this._doInitialize();
|
|
||||||
return this.initializePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _doInitialize(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Generate client ID based on current origin
|
|
||||||
const clientId = this.getClientId();
|
|
||||||
|
|
||||||
// Initialize both OAuth clients
|
|
||||||
this.oauthClient = await BrowserOAuthClient.load({
|
|
||||||
clientId: clientId,
|
|
||||||
handleResolver: 'https://bsky.social',
|
|
||||||
plcDirectoryUrl: 'https://plc.directory',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.oauthClientSyuIs = await BrowserOAuthClient.load({
|
|
||||||
clientId: clientId,
|
|
||||||
handleResolver: 'https://syu.is',
|
|
||||||
plcDirectoryUrl: 'https://plc.syu.is',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to restore existing session from either client
|
|
||||||
let result = await this.oauthClient.init();
|
|
||||||
if (!result?.session) {
|
|
||||||
result = await this.oauthClientSyuIs.init();
|
|
||||||
}
|
|
||||||
if (result?.session) {
|
|
||||||
|
|
||||||
// Create Agent instance with proper configuration
|
|
||||||
|
|
||||||
|
|
||||||
// Delete the old agent initialization code - we'll create it properly below
|
|
||||||
|
|
||||||
// Set the session after creating the agent
|
|
||||||
// The session object from BrowserOAuthClient appears to be a special object
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Try to iterate over the session object
|
|
||||||
if (result.session) {
|
|
||||||
|
|
||||||
for (const key in result.session) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session has methods
|
|
||||||
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrowserOAuthClient might return a Session object that needs to be used with the agent
|
|
||||||
// Let's try to use the session object directly with the agent
|
|
||||||
if (result.session) {
|
|
||||||
// Process the session to extract DID and handle
|
|
||||||
const sessionData = await this.processSession(result.session);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
this.initializePromise = null; // Reset on error to allow retry
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processSession(session: any): Promise<{ did: string; handle: string }> {
|
|
||||||
const did = session.sub || session.did;
|
|
||||||
let handle = session.handle || 'unknown';
|
|
||||||
|
|
||||||
// Create Agent directly with session (per official docs)
|
|
||||||
try {
|
|
||||||
this.agent = new Agent(session);
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to dpopFetch method
|
|
||||||
this.agent = new Agent({
|
|
||||||
service: session.server?.serviceEndpoint || 'https://bsky.social',
|
|
||||||
fetch: session.dpopFetch
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store basic session info
|
|
||||||
(this as any)._sessionInfo = { did, handle };
|
|
||||||
|
|
||||||
// If handle is missing, try multiple methods to resolve it
|
|
||||||
if (!handle || handle === 'unknown') {
|
|
||||||
|
|
||||||
|
|
||||||
// Method 1: Try using the agent to get profile
|
|
||||||
try {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
const profile = await this.agent.getProfile({ actor: did });
|
|
||||||
if (profile.data.handle) {
|
|
||||||
handle = profile.data.handle;
|
|
||||||
(this as any)._sessionInfo.handle = handle;
|
|
||||||
|
|
||||||
return { did, handle };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Try using describeRepo
|
|
||||||
try {
|
|
||||||
const repoDesc = await this.agent.com.atproto.repo.describeRepo({
|
|
||||||
repo: did
|
|
||||||
});
|
|
||||||
if (repoDesc.data.handle) {
|
|
||||||
handle = repoDesc.data.handle;
|
|
||||||
(this as any)._sessionInfo.handle = handle;
|
|
||||||
|
|
||||||
return { did, handle };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Fallback for admin DID
|
|
||||||
const adminDid = import.meta.env.VITE_ADMIN_DID;
|
|
||||||
if (did === adminDid) {
|
|
||||||
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
|
|
||||||
handle = new URL(appHost).hostname;
|
|
||||||
(this as any)._sessionInfo.handle = handle;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { did, handle };
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientId(): string {
|
|
||||||
// Use environment variable if available
|
|
||||||
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
|
||||||
if (envClientId) {
|
|
||||||
|
|
||||||
return envClientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = window.location.origin;
|
|
||||||
|
|
||||||
// For localhost development, use undefined for loopback client
|
|
||||||
// The BrowserOAuthClient will handle this automatically
|
|
||||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
|
||||||
|
|
||||||
return undefined as any; // Loopback client
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: use origin-based client metadata
|
|
||||||
return `${origin}/client-metadata.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async initiateOAuthFlow(handle?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
|
||||||
await this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
|
||||||
throw new Error('Failed to initialize OAuth clients');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If handle is not provided, prompt user
|
|
||||||
if (!handle) {
|
|
||||||
handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):');
|
|
||||||
if (!handle) {
|
|
||||||
throw new Error('Handle is required for authentication');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which OAuth client to use
|
|
||||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
|
||||||
let allowedHandles: string[] = [];
|
|
||||||
try {
|
|
||||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
|
||||||
} catch {
|
|
||||||
allowedHandles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
|
|
||||||
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
|
|
||||||
|
|
||||||
// Start OAuth authorization flow
|
|
||||||
const authUrl = await oauthClient.authorize(handle, {
|
|
||||||
scope: 'atproto transition:generic',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to authorization server
|
|
||||||
window.location.href = authUrl.toString();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// BrowserOAuthClient should automatically handle the callback
|
|
||||||
// We just need to initialize it and it will process the current URL
|
|
||||||
if (!this.oauthClient) {
|
|
||||||
|
|
||||||
await this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.oauthClient) {
|
|
||||||
throw new Error('Failed to initialize OAuth client');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Call init() again to process the callback URL
|
|
||||||
const result = await this.oauthClient.init();
|
|
||||||
|
|
||||||
|
|
||||||
if (result?.session) {
|
|
||||||
// Process the session
|
|
||||||
return this.processSession(result.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no session yet, wait a bit and try again
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Try to check session again
|
|
||||||
const sessionCheck = await this.checkSession();
|
|
||||||
if (sessionCheck) {
|
|
||||||
|
|
||||||
return sessionCheck;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return null;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkSession(): Promise<{ did: string; handle: string } | null> {
|
|
||||||
try {
|
|
||||||
if (!this.oauthClient) {
|
|
||||||
await this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.oauthClient) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.oauthClient.init();
|
|
||||||
|
|
||||||
if (result?.session) {
|
|
||||||
// Use the common session processing method
|
|
||||||
return this.processSession(result.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getAgent(): Agent | null {
|
|
||||||
return this.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSession(): AtprotoSession | null {
|
|
||||||
|
|
||||||
|
|
||||||
// First check if we have an agent with session
|
|
||||||
if (this.agent?.session) {
|
|
||||||
const session = {
|
|
||||||
did: this.agent.session.did,
|
|
||||||
handle: this.agent.session.handle || 'unknown',
|
|
||||||
accessJwt: this.agent.session.accessJwt || '',
|
|
||||||
refreshJwt: this.agent.session.refreshJwt || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no agent.session but we have stored session info, return that
|
|
||||||
if ((this as any)._sessionInfo) {
|
|
||||||
const session = {
|
|
||||||
did: (this as any)._sessionInfo.did,
|
|
||||||
handle: (this as any)._sessionInfo.handle,
|
|
||||||
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
|
|
||||||
refreshJwt: 'dpop-protected',
|
|
||||||
};
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
|
||||||
return !!this.agent || !!(this as any)._sessionInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUser(): { did: string; handle: string } | null {
|
|
||||||
const session = this.getSession();
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
did: session.did,
|
|
||||||
handle: session.handle
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Clear Agent
|
|
||||||
this.agent = null;
|
|
||||||
|
|
||||||
// Clear BrowserOAuthClient session
|
|
||||||
if (this.oauthClient) {
|
|
||||||
try {
|
|
||||||
// BrowserOAuthClient may have a revoke or signOut method
|
|
||||||
if (typeof (this.oauthClient as any).signOut === 'function') {
|
|
||||||
await (this.oauthClient as any).signOut();
|
|
||||||
} else if (typeof (this.oauthClient as any).revoke === 'function') {
|
|
||||||
await (this.oauthClient as any).revoke();
|
|
||||||
}
|
|
||||||
} catch (oauthError) {
|
|
||||||
// Ignore logout errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the OAuth client to force re-initialization
|
|
||||||
this.oauthClient = null;
|
|
||||||
this.initializePromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any stored session data
|
|
||||||
localStorage.removeItem('atproto_session');
|
|
||||||
sessionStorage.clear();
|
|
||||||
|
|
||||||
// Clear all OAuth-related storage
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear internal session info
|
|
||||||
(this as any)._sessionInfo = null;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Force page reload to ensure clean state
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// カードデータをatproto collectionに保存
|
|
||||||
async saveCardToBox(userCards: any[]): Promise<void> {
|
|
||||||
// Ensure we have a valid session
|
|
||||||
const sessionInfo = await this.checkSession();
|
|
||||||
if (!sessionInfo) {
|
|
||||||
throw new Error('認証が必要です。ログインしてください。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const did = sessionInfo.did;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure we have a fresh agent
|
|
||||||
if (!this.agent) {
|
|
||||||
throw new Error('Agentが初期化されていません。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const collection = 'ai.card.box';
|
|
||||||
const rkey = 'self';
|
|
||||||
const createdAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// カードボックスのレコード
|
|
||||||
const record = {
|
|
||||||
$type: 'ai.card.box',
|
|
||||||
cards: userCards.map(card => ({
|
|
||||||
id: card.id,
|
|
||||||
cp: card.cp,
|
|
||||||
status: card.status,
|
|
||||||
skill: card.skill,
|
|
||||||
owner_did: card.owner_did,
|
|
||||||
obtained_at: card.obtained_at,
|
|
||||||
is_unique: card.is_unique,
|
|
||||||
unique_id: card.unique_id
|
|
||||||
|
|
||||||
})),
|
|
||||||
total_cards: userCards.length,
|
|
||||||
updated_at: createdAt,
|
|
||||||
createdAt: createdAt
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Use Agent's com.atproto.repo.putRecord method
|
|
||||||
const response = await this.agent.com.atproto.repo.putRecord({
|
|
||||||
repo: did,
|
|
||||||
collection: collection,
|
|
||||||
rkey: rkey,
|
|
||||||
record: record
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ai.card.boxからカード一覧を取得
|
|
||||||
async getCardsFromBox(): Promise<any> {
|
|
||||||
// Ensure we have a valid session
|
|
||||||
const sessionInfo = await this.checkSession();
|
|
||||||
if (!sessionInfo) {
|
|
||||||
throw new Error('認証が必要です。ログインしてください。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const did = sessionInfo.did;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure we have a fresh agent
|
|
||||||
if (!this.agent) {
|
|
||||||
throw new Error('Agentが初期化されていません。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.agent.com.atproto.repo.getRecord({
|
|
||||||
repo: did,
|
|
||||||
collection: 'ai.card.box',
|
|
||||||
rkey: 'self'
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Convert to expected format
|
|
||||||
const result = {
|
|
||||||
records: [{
|
|
||||||
uri: `at://${did}/ai.card.box/self`,
|
|
||||||
cid: response.data.cid,
|
|
||||||
value: response.data.value
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
|
|
||||||
// If record doesn't exist, return empty
|
|
||||||
if (error.toString().includes('RecordNotFound')) {
|
|
||||||
return { records: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ai.card.boxのコレクションを削除
|
|
||||||
async deleteCardBox(): Promise<void> {
|
|
||||||
// Ensure we have a valid session
|
|
||||||
const sessionInfo = await this.checkSession();
|
|
||||||
if (!sessionInfo) {
|
|
||||||
throw new Error('認証が必要です。ログインしてください。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const did = sessionInfo.did;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure we have a fresh agent
|
|
||||||
if (!this.agent) {
|
|
||||||
throw new Error('Agentが初期化されていません。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.agent.com.atproto.repo.deleteRecord({
|
|
||||||
repo: did,
|
|
||||||
collection: 'ai.card.box',
|
|
||||||
rkey: 'self'
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 手動でトークンを設定(開発・デバッグ用)
|
|
||||||
setManualTokens(accessJwt: string, refreshJwt: string): void {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// For backward compatibility, store in localStorage
|
|
||||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
|
|
||||||
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
|
|
||||||
const session: AtprotoSession = {
|
|
||||||
did: adminDid,
|
|
||||||
handle: new URL(appHost).hostname,
|
|
||||||
accessJwt: accessJwt,
|
|
||||||
refreshJwt: refreshJwt
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem('atproto_session', JSON.stringify(session));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 後方互換性のための従来関数
|
|
||||||
saveSessionToStorage(session: AtprotoSession): void {
|
|
||||||
|
|
||||||
localStorage.setItem('atproto_session', JSON.stringify(session));
|
|
||||||
}
|
|
||||||
|
|
||||||
async backupUserCards(userCards: any[]): Promise<void> {
|
|
||||||
return this.saveCardToBox(userCards);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const atprotoOAuthService = new AtprotoOAuthService();
|
|
||||||
export type { AtprotoSession };
|
|
@@ -1,109 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
|
||||||
|
|
||||||
interface LoginRequest {
|
|
||||||
identifier: string; // Handle or DID
|
|
||||||
password: string; // App password
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
did: string;
|
|
||||||
handle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
did: string;
|
|
||||||
handle: string;
|
|
||||||
avatar?: string;
|
|
||||||
displayName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthService {
|
|
||||||
private token: string | null = null;
|
|
||||||
private user: User | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Load token from localStorage
|
|
||||||
this.token = localStorage.getItem('ai_card_token');
|
|
||||||
|
|
||||||
// Set default auth header if token exists
|
|
||||||
if (this.token) {
|
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(identifier: string, password: string): Promise<LoginResponse> {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<LoginResponse>(`${API_BASE}/auth/login`, {
|
|
||||||
identifier,
|
|
||||||
password
|
|
||||||
});
|
|
||||||
|
|
||||||
const { access_token, did, handle } = response.data;
|
|
||||||
|
|
||||||
// Store token
|
|
||||||
this.token = access_token;
|
|
||||||
localStorage.setItem('ai_card_token', access_token);
|
|
||||||
|
|
||||||
// Set auth header
|
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
|
|
||||||
|
|
||||||
// Store user info
|
|
||||||
this.user = { did, handle };
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Login failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await axios.post(`${API_BASE}/auth/logout`);
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear token
|
|
||||||
this.token = null;
|
|
||||||
this.user = null;
|
|
||||||
localStorage.removeItem('ai_card_token');
|
|
||||||
delete axios.defaults.headers.common['Authorization'];
|
|
||||||
}
|
|
||||||
|
|
||||||
async verify(): Promise<User | null> {
|
|
||||||
if (!this.token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get<User & { valid: boolean }>(`${API_BASE}/auth/verify`);
|
|
||||||
if (response.data.valid) {
|
|
||||||
this.user = {
|
|
||||||
did: response.data.did,
|
|
||||||
handle: response.data.handle
|
|
||||||
};
|
|
||||||
return this.user;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Token is invalid
|
|
||||||
this.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUser(): User | null {
|
|
||||||
return this.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
|
||||||
return this.token !== null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authService = new AuthService();
|
|
||||||
export type { User, LoginRequest, LoginResponse };
|
|
@@ -65,6 +65,8 @@ export class OAuthService {
|
|||||||
async processSession(session) {
|
async processSession(session) {
|
||||||
const did = session.sub || session.did
|
const did = session.sub || session.did
|
||||||
let handle = session.handle || 'unknown'
|
let handle = session.handle || 'unknown'
|
||||||
|
let displayName = null
|
||||||
|
let avatar = null
|
||||||
|
|
||||||
// Create Agent directly with session (per official docs)
|
// Create Agent directly with session (per official docs)
|
||||||
try {
|
try {
|
||||||
@@ -77,21 +79,43 @@ export class OAuthService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessionInfo = { did, handle }
|
// Get profile information using authenticated agent
|
||||||
|
// Skip test DIDs
|
||||||
// Resolve handle if missing
|
if (this.agent && did && !did.includes('test-')) {
|
||||||
if (handle === 'unknown' && this.agent) {
|
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
const profile = await this.agent.getProfile({ actor: did })
|
const profile = await this.agent.getProfile({ actor: did })
|
||||||
handle = profile.data.handle
|
handle = profile.data.handle || handle
|
||||||
this.sessionInfo.handle = handle
|
displayName = profile.data.displayName || null
|
||||||
|
avatar = profile.data.avatar || null
|
||||||
|
|
||||||
|
console.log('Profile fetched from session:', {
|
||||||
|
did,
|
||||||
|
handle,
|
||||||
|
displayName,
|
||||||
|
avatar: avatar ? 'present' : 'none'
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Failed to resolve handle:', error)
|
console.log('Failed to get profile from session:', error)
|
||||||
|
// Keep the basic info we have
|
||||||
}
|
}
|
||||||
|
} else if (did && did.includes('test-')) {
|
||||||
|
console.log('Skipping profile fetch for test DID:', did)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { did, handle }
|
this.sessionInfo = {
|
||||||
|
did,
|
||||||
|
handle,
|
||||||
|
displayName,
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
did,
|
||||||
|
handle,
|
||||||
|
displayName,
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(handle) {
|
async login(handle) {
|
@@ -1,331 +0,0 @@
|
|||||||
.card {
|
|
||||||
width: 250px;
|
|
||||||
height: 380px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
|
||||||
border: 2px solid #333;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-inner {
|
|
||||||
padding: 20px;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rarity effects */
|
|
||||||
.card-normal {
|
|
||||||
border-color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-rare {
|
|
||||||
border-color: #4a90e2;
|
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-super-rare {
|
|
||||||
border-color: #9c27b0;
|
|
||||||
background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-kira {
|
|
||||||
border-color: #ffd700;
|
|
||||||
background: linear-gradient(135deg, #232526 0%, #414345 100%);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-kira::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent 30%,
|
|
||||||
rgba(255, 215, 0, 0.1) 50%,
|
|
||||||
transparent 70%
|
|
||||||
);
|
|
||||||
animation: shimmer 3s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-unique {
|
|
||||||
border-color: #ff00ff;
|
|
||||||
background: linear-gradient(135deg, #000000 0%, #1a0033 100%);
|
|
||||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-unique::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: radial-gradient(
|
|
||||||
circle at center,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 0, 255, 0.2) 100%
|
|
||||||
);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card content */
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #888;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-image-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 150px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-image {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
font-size: 28px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--card-color, #fff);
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unique-badge {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 5px 15px;
|
|
||||||
background: linear-gradient(90deg, #ff00ff, #00ffff);
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
animation: glow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-skill {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 0.5; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
100% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow {
|
|
||||||
0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
|
|
||||||
50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); }
|
|
||||||
100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simple Card Styles */
|
|
||||||
.card-simple {
|
|
||||||
width: 240px;
|
|
||||||
height: auto;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-frame {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 3/4;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #1a1a1a;
|
|
||||||
padding: 25px 25px 30px 25px;
|
|
||||||
border: 3px solid #666;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Normal card - no effects */
|
|
||||||
.card-simple.card-normal .card-frame {
|
|
||||||
border-color: #666;
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unique (rare) card - glowing effects */
|
|
||||||
.card-simple.card-unique .card-frame {
|
|
||||||
border-color: #ffd700;
|
|
||||||
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Particle/grainy texture for rare cards */
|
|
||||||
.card-simple.card-unique .card-frame::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px),
|
|
||||||
repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px);
|
|
||||||
background-size: 20px 20px, 30px 30px;
|
|
||||||
opacity: 0.8;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reflection effect for rare cards */
|
|
||||||
.card-simple.card-unique .card-frame::after {
|
|
||||||
content: "";
|
|
||||||
height: 100%;
|
|
||||||
width: 40px;
|
|
||||||
position: absolute;
|
|
||||||
top: -180px;
|
|
||||||
left: 0;
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 215, 0, 0.8) 20%,
|
|
||||||
rgba(255, 255, 0, 0.9) 40%,
|
|
||||||
rgba(255, 223, 0, 1) 50%,
|
|
||||||
rgba(255, 255, 0, 0.9) 60%,
|
|
||||||
rgba(255, 215, 0, 0.8) 80%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
animation: gold-reflection 6s ease-in-out infinite;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gold-reflection {
|
|
||||||
0% { transform: scale(0) rotate(45deg); opacity: 0; }
|
|
||||||
15% { transform: scale(0) rotate(45deg); opacity: 0; }
|
|
||||||
17% { transform: scale(4) rotate(45deg); opacity: 0.8; }
|
|
||||||
20% { transform: scale(50) rotate(45deg); opacity: 0; }
|
|
||||||
100% { transform: scale(50) rotate(45deg); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glowing backlight effect */
|
|
||||||
.card-simple.card-unique {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-simple.card-unique::after {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
top: 5px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: -1;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
transform: scale(0.95);
|
|
||||||
filter: blur(15px);
|
|
||||||
background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glowing border effect for rare cards */
|
|
||||||
.card-simple.card-unique .card-frame {
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px rgba(255, 215, 0, 0.5),
|
|
||||||
inset 0 0 10px rgba(255, 215, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.card-image-simple {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cp-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 50px;
|
|
||||||
background: #333;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border: 2px solid #666;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-simple.card-unique .card-cp-bar {
|
|
||||||
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
|
|
||||||
border-color: #ffd700;
|
|
||||||
box-shadow:
|
|
||||||
0 0 5px rgba(255, 215, 0, 0.3),
|
|
||||||
inset 0 0 5px rgba(255, 215, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.cp-value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@@ -1,196 +0,0 @@
|
|||||||
.card-box-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-box-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 2px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-box-header h3 {
|
|
||||||
color: #495057;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uri-display {
|
|
||||||
background: #e3f2fd;
|
|
||||||
border: 1px solid #bbdefb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uri-display p {
|
|
||||||
margin: 0;
|
|
||||||
color: #1565c0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uri-display code {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #90caf9;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #0d47a1;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-button,
|
|
||||||
.refresh-button,
|
|
||||||
.retry-button,
|
|
||||||
.delete-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-button {
|
|
||||||
background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-button {
|
|
||||||
background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-button {
|
|
||||||
background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-button {
|
|
||||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-display {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-display h4 {
|
|
||||||
color: #495057;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-content {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #495057;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-stats {
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-stats p {
|
|
||||||
margin: 0;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-card-item {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-info {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-box {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #6c757d;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-box p {
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading,
|
|
||||||
.error {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #dc3545;
|
|
||||||
background: #f8d7da;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
@@ -1,170 +0,0 @@
|
|||||||
.card-list-container {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-header h1 {
|
|
||||||
color: #fff;
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-header p {
|
|
||||||
color: #999;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 30px;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simple grid layout for user-page style */
|
|
||||||
.card-list-simple-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-simple-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-button {
|
|
||||||
background: linear-gradient(135deg, #333 0%, #555 100%);
|
|
||||||
color: white;
|
|
||||||
border: 2px solid #666;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
background: linear-gradient(135deg, #444 0%, #666 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-info-details {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 240px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-info-details p {
|
|
||||||
margin: 5px 0;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-info-details p strong {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta p {
|
|
||||||
margin: 5px 0;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-meta p:first-child {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-description {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #999;
|
|
||||||
font-style: italic;
|
|
||||||
margin-top: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-info {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading, .error {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ff4757;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.card-list-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-list-header h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,172 +0,0 @@
|
|||||||
.collection-analysis {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
margin: 20px 0;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-analysis h3 {
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-distribution {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-distribution h4 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-bars {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-name {
|
|
||||||
min-width: 80px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-container {
|
|
||||||
flex: 1;
|
|
||||||
height: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); }
|
|
||||||
.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); }
|
|
||||||
.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); }
|
|
||||||
.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); }
|
|
||||||
.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); }
|
|
||||||
|
|
||||||
.rarity-count {
|
|
||||||
min-width: 40px;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendations {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendations h4 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendations ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendations li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-analysis,
|
|
||||||
.analyze-button,
|
|
||||||
.retry-button {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-analysis:hover,
|
|
||||||
.analyze-button:hover,
|
|
||||||
.retry-button:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-loading,
|
|
||||||
.analysis-error,
|
|
||||||
.analysis-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top: 3px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-error p {
|
|
||||||
color: #ffcdd2;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-empty p {
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
@@ -1,174 +0,0 @@
|
|||||||
.gacha-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.9);
|
|
||||||
z-index: 1000;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-final {
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -80px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-button {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-hint {
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 15px;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.7; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.gacha-opening {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gacha-pack {
|
|
||||||
width: 200px;
|
|
||||||
height: 280px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 16px;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pack-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: -20px;
|
|
||||||
left: -20px;
|
|
||||||
right: -20px;
|
|
||||||
bottom: -20px;
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
|
||||||
animation: glow-pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Effect variations */
|
|
||||||
.effect-normal {
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effect-rare {
|
|
||||||
background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effect-kira {
|
|
||||||
background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effect-kira::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 60,40 100,50 60,60 50,100 40,60 0,50 40,40" fill="rgba(255,215,0,0.1)"/></svg>');
|
|
||||||
background-size: 50px 50px;
|
|
||||||
animation: sparkle 3s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.effect-unique {
|
|
||||||
background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unique-effect {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unique-particles {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(circle, #ff00ff 1px, transparent 1px),
|
|
||||||
radial-gradient(circle, #00ffff 1px, transparent 1px);
|
|
||||||
background-size: 50px 50px, 30px 30px;
|
|
||||||
background-position: 0 0, 25px 25px;
|
|
||||||
animation: particle-float 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unique-burst {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%);
|
|
||||||
animation: burst 1s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes glow-pulse {
|
|
||||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
|
||||||
50% { opacity: 1; transform: scale(1.1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sparkle {
|
|
||||||
0% { transform: translateY(0) rotate(0deg); }
|
|
||||||
100% { transform: translateY(-100vh) rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes particle-float {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(-50px, -100px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes burst {
|
|
||||||
0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }
|
|
||||||
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
|
|
||||||
}
|
|
@@ -1,219 +0,0 @@
|
|||||||
.gacha-stats {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
margin: 20px 0;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gacha-stats h3 {
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-overview {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-card {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-label {
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-stats {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-stats h4 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-stat {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-stat::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: var(--rarity-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-stat.rarity-common { --rarity-color: #4CAF50; }
|
|
||||||
.rarity-stat.rarity-rare { --rarity-color: #2196F3; }
|
|
||||||
.rarity-stat.rarity-epic { --rarity-color: #9C27B0; }
|
|
||||||
.rarity-stat.rarity-legendary { --rarity-color: #FF9800; }
|
|
||||||
.rarity-stat.rarity-mythic { --rarity-color: #F44336; }
|
|
||||||
|
|
||||||
.rarity-count {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity-name {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
text-transform: capitalize;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-rate {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-activity {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-activity h4 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-details {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-rarity {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-rarity.rarity-common { background: #4CAF50; }
|
|
||||||
.card-rarity.rarity-rare { background: #2196F3; }
|
|
||||||
.card-rarity.rarity-epic { background: #9C27B0; }
|
|
||||||
.card-rarity.rarity-legendary { background: #FF9800; }
|
|
||||||
.card-rarity.rarity-mythic { background: #F44336; }
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-stats,
|
|
||||||
.load-stats-button,
|
|
||||||
.retry-button {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-stats:hover,
|
|
||||||
.load-stats-button:hover,
|
|
||||||
.retry-button:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-loading,
|
|
||||||
.stats-error,
|
|
||||||
.stats-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top: 3px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-error p {
|
|
||||||
color: #ffcdd2;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-empty p {
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
@@ -1,243 +0,0 @@
|
|||||||
.login-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-modal {
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 40px;
|
|
||||||
max-width: 450px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-mode-selector {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #ccc;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-button.active {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-button:hover:not(.active) {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-login {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-info {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-info h3 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-info p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-login-button {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-login-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-login-button:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-modal h2 {
|
|
||||||
margin: 0 0 30px 0;
|
|
||||||
font-size: 28px;
|
|
||||||
text-align: center;
|
|
||||||
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: white;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #fff700;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group small {
|
|
||||||
display: block;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: #888;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group small a {
|
|
||||||
color: #fff700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group small a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: rgba(255, 71, 87, 0.1);
|
|
||||||
border: 1px solid rgba(255, 71, 87, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #ff4757;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button,
|
|
||||||
.cancel-button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 14px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
background: linear-gradient(135deg, #fff700 0%, #ffd700 100%);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-button {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: white;
|
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-button:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-info {
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-info p {
|
|
||||||
color: #888;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dev-notice {
|
|
||||||
background: rgba(255, 193, 7, 0.1);
|
|
||||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 10px 0;
|
|
||||||
color: #ffc107;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
@@ -1,135 +0,0 @@
|
|||||||
// Simple console test for OAuth app
|
|
||||||
// This runs before 'npm run preview' to display test results
|
|
||||||
|
|
||||||
// Mock import.meta.env for Node.js environment
|
|
||||||
(global as any).import = {
|
|
||||||
meta: {
|
|
||||||
env: {
|
|
||||||
VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
|
|
||||||
VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
|
|
||||||
VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
|
|
||||||
VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
|
|
||||||
VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
|
||||||
VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simple implementation of functions for testing
|
|
||||||
function detectPdsFromHandle(handle: string): string {
|
|
||||||
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
|
||||||
return 'syu.is';
|
|
||||||
}
|
|
||||||
if (handle.endsWith('.bsky.social')) {
|
|
||||||
return 'bsky.social';
|
|
||||||
}
|
|
||||||
// Default case - check if it's in the allowed list
|
|
||||||
const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
|
||||||
if (allowedHandles.includes(handle)) {
|
|
||||||
return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
|
||||||
}
|
|
||||||
return 'bsky.social';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNetworkConfig(pds: string) {
|
|
||||||
switch (pds) {
|
|
||||||
case 'bsky.social':
|
|
||||||
case 'bsky.app':
|
|
||||||
return {
|
|
||||||
pdsApi: `https://${pds}`,
|
|
||||||
plcApi: 'https://plc.directory',
|
|
||||||
bskyApi: 'https://public.api.bsky.app',
|
|
||||||
webUrl: 'https://bsky.app'
|
|
||||||
};
|
|
||||||
case 'syu.is':
|
|
||||||
return {
|
|
||||||
pdsApi: 'https://syu.is',
|
|
||||||
plcApi: 'https://plc.syu.is',
|
|
||||||
bskyApi: 'https://bsky.syu.is',
|
|
||||||
webUrl: 'https://web.syu.is'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
pdsApi: `https://${pds}`,
|
|
||||||
plcApi: 'https://plc.directory',
|
|
||||||
bskyApi: 'https://public.api.bsky.app',
|
|
||||||
webUrl: 'https://bsky.app'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main test execution
|
|
||||||
console.log('\n=== OAuth App Configuration Tests ===\n');
|
|
||||||
|
|
||||||
// Test 1: Handle input behavior
|
|
||||||
console.log('1. Handle Input → PDS Detection:');
|
|
||||||
const testHandles = [
|
|
||||||
'syui.ai',
|
|
||||||
'syui.syu.is',
|
|
||||||
'syui.syui.ai',
|
|
||||||
'test.bsky.social',
|
|
||||||
'unknown.handle'
|
|
||||||
];
|
|
||||||
|
|
||||||
testHandles.forEach(handle => {
|
|
||||||
const pds = detectPdsFromHandle(handle);
|
|
||||||
const config = getNetworkConfig(pds);
|
|
||||||
console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: Environment variable impact
|
|
||||||
console.log('\n2. Current Environment Configuration:');
|
|
||||||
const env = (global as any).import.meta.env;
|
|
||||||
console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`);
|
|
||||||
console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`);
|
|
||||||
console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`);
|
|
||||||
console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
|
|
||||||
console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
|
|
||||||
|
|
||||||
// Test 3: API endpoint generation
|
|
||||||
console.log('\n3. Generated API Endpoints:');
|
|
||||||
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
|
|
||||||
const adminConfig = getNetworkConfig(adminPds);
|
|
||||||
console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
|
|
||||||
console.log(` Admin API endpoints:`);
|
|
||||||
console.log(` - PDS API: ${adminConfig.pdsApi}`);
|
|
||||||
console.log(` - Bsky API: ${adminConfig.bskyApi}`);
|
|
||||||
console.log(` - Web URL: ${adminConfig.webUrl}`);
|
|
||||||
|
|
||||||
// Test 4: Collection URLs
|
|
||||||
console.log('\n4. Collection API URLs:');
|
|
||||||
const baseCollection = env.VITE_OAUTH_COLLECTION;
|
|
||||||
console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
|
|
||||||
console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
|
|
||||||
console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
|
|
||||||
console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
|
|
||||||
|
|
||||||
// Test 5: OAuth routing logic
|
|
||||||
console.log('\n5. OAuth Authorization Logic:');
|
|
||||||
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
|
||||||
console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`);
|
|
||||||
console.log(` OAuth scenarios:`);
|
|
||||||
|
|
||||||
const oauthTestCases = [
|
|
||||||
'syui.ai', // Should use syu.is (in allowed list)
|
|
||||||
'test.syu.is', // Should use syu.is (*.syu.is pattern)
|
|
||||||
'user.bsky.social' // Should use bsky.social (default)
|
|
||||||
];
|
|
||||||
|
|
||||||
oauthTestCases.forEach(handle => {
|
|
||||||
const pds = detectPdsFromHandle(handle);
|
|
||||||
const isAllowed = allowedHandles.includes(handle);
|
|
||||||
const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' :
|
|
||||||
isAllowed ? 'in allowed list' :
|
|
||||||
'default';
|
|
||||||
console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 6: AI Profile Resolution
|
|
||||||
console.log('\n6. AI Profile Resolution:');
|
|
||||||
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
|
|
||||||
const aiConfig = getNetworkConfig(aiPds);
|
|
||||||
console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
|
|
||||||
console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
|
|
||||||
|
|
||||||
console.log('\n=== Tests Complete ===\n');
|
|
@@ -1,141 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { getAppConfig } from '../config/app';
|
|
||||||
import { detectPdsFromHandle, getNetworkConfig } from '../App';
|
|
||||||
|
|
||||||
// Test helper to mock environment variables
|
|
||||||
const mockEnv = (vars: Record<string, string>) => {
|
|
||||||
Object.keys(vars).forEach(key => {
|
|
||||||
(import.meta.env as any)[key] = vars[key];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('OAuth App Tests', () => {
|
|
||||||
describe('Handle Input Behavior', () => {
|
|
||||||
it('should detect PDS for syui.ai (Bluesky)', () => {
|
|
||||||
const pds = detectPdsFromHandle('syui.ai');
|
|
||||||
expect(pds).toBe('bsky.social');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect PDS for syui.syu.is (syu.is)', () => {
|
|
||||||
const pds = detectPdsFromHandle('syui.syu.is');
|
|
||||||
expect(pds).toBe('syu.is');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect PDS for syui.syui.ai (syu.is)', () => {
|
|
||||||
const pds = detectPdsFromHandle('syui.syui.ai');
|
|
||||||
expect(pds).toBe('syu.is');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use network config for different PDS', () => {
|
|
||||||
const bskyConfig = getNetworkConfig('bsky.social');
|
|
||||||
expect(bskyConfig.pdsApi).toBe('https://bsky.social');
|
|
||||||
expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
|
|
||||||
expect(bskyConfig.webUrl).toBe('https://bsky.app');
|
|
||||||
|
|
||||||
const syuisConfig = getNetworkConfig('syu.is');
|
|
||||||
expect(syuisConfig.pdsApi).toBe('https://syu.is');
|
|
||||||
expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
|
|
||||||
expect(syuisConfig.webUrl).toBe('https://web.syu.is');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Environment Variable Changes', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset environment variables
|
|
||||||
delete (import.meta.env as any).VITE_ATPROTO_PDS;
|
|
||||||
delete (import.meta.env as any).VITE_ADMIN_HANDLE;
|
|
||||||
delete (import.meta.env as any).VITE_AI_HANDLE;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use correct PDS for AI profile', () => {
|
|
||||||
mockEnv({
|
|
||||||
VITE_ATPROTO_PDS: 'syu.is',
|
|
||||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
|
||||||
VITE_AI_HANDLE: 'ai.syui.ai'
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = getAppConfig();
|
|
||||||
expect(config.atprotoPds).toBe('syu.is');
|
|
||||||
expect(config.adminHandle).toBe('ai.syui.ai');
|
|
||||||
expect(config.aiHandle).toBe('ai.syui.ai');
|
|
||||||
|
|
||||||
// Network config should use syu.is endpoints
|
|
||||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
|
||||||
expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should construct correct API requests for admin userlist', () => {
|
|
||||||
mockEnv({
|
|
||||||
VITE_ATPROTO_PDS: 'syu.is',
|
|
||||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
|
||||||
VITE_OAUTH_COLLECTION: 'ai.syui.log'
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = getAppConfig();
|
|
||||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
|
||||||
const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
|
|
||||||
|
|
||||||
expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Login Flow', () => {
|
|
||||||
it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
|
|
||||||
mockEnv({
|
|
||||||
VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
|
||||||
VITE_ATPROTO_PDS: 'syu.is'
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = getAppConfig();
|
|
||||||
const handle = 'syui.ai';
|
|
||||||
|
|
||||||
// Check if handle is in allowed list
|
|
||||||
expect(config.allowedHandles).toContain(handle);
|
|
||||||
|
|
||||||
// Should use configured PDS for OAuth
|
|
||||||
const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
|
|
||||||
expect(expectedAuthUrl).toContain('syu.is');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use syu.is OAuth for *.syu.is handles', () => {
|
|
||||||
const handle = 'test.syu.is';
|
|
||||||
const pds = detectPdsFromHandle(handle);
|
|
||||||
expect(pds).toBe('syu.is');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Terminal display test output
|
|
||||||
export function runTerminalTests() {
|
|
||||||
console.log('\n=== OAuth App Tests ===\n');
|
|
||||||
|
|
||||||
// Test 1: Handle input behavior
|
|
||||||
console.log('1. Handle Input Detection:');
|
|
||||||
const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
|
|
||||||
handles.forEach(handle => {
|
|
||||||
const pds = detectPdsFromHandle(handle);
|
|
||||||
console.log(` ${handle} → PDS: ${pds}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: Environment variable impact
|
|
||||||
console.log('\n2. Environment Variables:');
|
|
||||||
const config = getAppConfig();
|
|
||||||
console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`);
|
|
||||||
console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`);
|
|
||||||
console.log(` VITE_AI_HANDLE: ${config.aiHandle}`);
|
|
||||||
console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`);
|
|
||||||
|
|
||||||
// Test 3: API endpoints
|
|
||||||
console.log('\n3. API Endpoints:');
|
|
||||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
|
||||||
console.log(` Admin PDS API: ${networkConfig.pdsApi}`);
|
|
||||||
console.log(` Admin Bsky API: ${networkConfig.bskyApi}`);
|
|
||||||
console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
|
|
||||||
|
|
||||||
// Test 4: OAuth routing
|
|
||||||
console.log('\n4. OAuth Routing:');
|
|
||||||
console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
|
|
||||||
console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
|
|
||||||
|
|
||||||
console.log('\n=== End Tests ===\n');
|
|
||||||
}
|
|
@@ -1,24 +0,0 @@
|
|||||||
export enum CardRarity {
|
|
||||||
NORMAL = "normal",
|
|
||||||
RARE = "rare",
|
|
||||||
SUPER_RARE = "super_rare",
|
|
||||||
KIRA = "kira",
|
|
||||||
UNIQUE = "unique"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Card {
|
|
||||||
id: number;
|
|
||||||
cp: number;
|
|
||||||
status: CardRarity;
|
|
||||||
skill?: string;
|
|
||||||
owner_did: string;
|
|
||||||
obtained_at: string;
|
|
||||||
is_unique: boolean;
|
|
||||||
unique_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CardDrawResult {
|
|
||||||
card: Card;
|
|
||||||
is_new: boolean;
|
|
||||||
animation_type: string;
|
|
||||||
}
|
|
@@ -33,6 +33,12 @@ async function getDid(handle) {
|
|||||||
|
|
||||||
// DIDからプロフィール情報を取得
|
// DIDからプロフィール情報を取得
|
||||||
async function getProfile(did, handle) {
|
async function getProfile(did, handle) {
|
||||||
|
// Skip test DIDs
|
||||||
|
if (did && did.includes('test-')) {
|
||||||
|
logger.log('Skipping profile fetch for test DID:', did)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which public API to use based on handle
|
// Determine which public API to use based on handle
|
||||||
const pds = await getPdsFromHandle(handle)
|
const pds = await getPdsFromHandle(handle)
|
||||||
@@ -81,6 +87,11 @@ async function fetchFreshAvatar(handle, did) {
|
|||||||
|
|
||||||
// プロフィール取得
|
// プロフィール取得
|
||||||
const profile = await getProfile(actualDid, handle)
|
const profile = await getProfile(actualDid, handle)
|
||||||
|
if (!profile) {
|
||||||
|
// Test DID or profile fetch failed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const avatarUrl = profile.avatar || null
|
const avatarUrl = profile.avatar || 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<string> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const clientId = generateClientMetadata().client_id;
|
|
||||||
|
|
||||||
const header = {
|
|
||||||
alg: 'ES256',
|
|
||||||
typ: 'JWT',
|
|
||||||
kid: 'ai-card-oauth-key-1'
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
iss: clientId,
|
|
||||||
sub: clientId,
|
|
||||||
aud: tokenEndpoint,
|
|
||||||
iat: now,
|
|
||||||
exp: now + 300, // 5 minutes
|
|
||||||
jti: crypto.randomUUID()
|
|
||||||
};
|
|
||||||
|
|
||||||
return await OAuthKeyManager.signJWT(header, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service Worker alternative for intercepting requests
|
|
||||||
* (This is a more robust solution for production)
|
|
||||||
*/
|
|
||||||
export function registerOAuthServiceWorker() {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const swCode = `
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const url = new URL(event.request.url);
|
|
||||||
|
|
||||||
if (url.pathname.endsWith('/client-metadata.json')) {
|
|
||||||
event.respondWith(
|
|
||||||
new Response(JSON.stringify({
|
|
||||||
client_id: url.origin + '/client-metadata.json',
|
|
||||||
client_name: 'ai.card',
|
|
||||||
client_uri: url.origin,
|
|
||||||
redirect_uris: [url.origin + '/oauth/callback'],
|
|
||||||
response_types: ['code'],
|
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
|
||||||
token_endpoint_auth_method: 'private_key_jwt',
|
|
||||||
scope: 'atproto transition:generic',
|
|
||||||
subject_type: 'public',
|
|
||||||
application_type: 'web',
|
|
||||||
dpop_bound_access_tokens: true,
|
|
||||||
jwks_uri: url.origin + '/.well-known/jwks.json'
|
|
||||||
}, null, 2), {
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const blob = new Blob([swCode], { type: 'application/javascript' });
|
|
||||||
const swUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* OAuth JWKS key generation and management
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface JWK {
|
|
||||||
kty: string;
|
|
||||||
crv: string;
|
|
||||||
x: string;
|
|
||||||
y: string;
|
|
||||||
d?: string;
|
|
||||||
use: string;
|
|
||||||
kid: string;
|
|
||||||
alg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JWKS {
|
|
||||||
keys: JWK[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthKeyManager {
|
|
||||||
private static keyPair: CryptoKeyPair | null = null;
|
|
||||||
private static jwks: JWKS | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate or retrieve existing ECDSA key pair for OAuth
|
|
||||||
*/
|
|
||||||
static async getKeyPair(): Promise<CryptoKeyPair> {
|
|
||||||
if (this.keyPair) {
|
|
||||||
return this.keyPair;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to load from localStorage first
|
|
||||||
const storedKey = localStorage.getItem('oauth_private_key');
|
|
||||||
if (storedKey) {
|
|
||||||
try {
|
|
||||||
const keyData = JSON.parse(storedKey);
|
|
||||||
this.keyPair = await this.importKeyPair(keyData);
|
|
||||||
return this.keyPair;
|
|
||||||
} catch (error) {
|
|
||||||
localStorage.removeItem('oauth_private_key');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new key pair
|
|
||||||
this.keyPair = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'ECDSA',
|
|
||||||
namedCurve: 'P-256',
|
|
||||||
},
|
|
||||||
true, // extractable
|
|
||||||
['sign', 'verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store private key for persistence
|
|
||||||
await this.storeKeyPair(this.keyPair);
|
|
||||||
|
|
||||||
return this.keyPair;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get JWKS (JSON Web Key Set) for public key distribution
|
|
||||||
*/
|
|
||||||
static async getJWKS(): Promise<JWKS> {
|
|
||||||
if (this.jwks) {
|
|
||||||
return this.jwks;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyPair = await this.getKeyPair();
|
|
||||||
const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
||||||
|
|
||||||
this.jwks = {
|
|
||||||
keys: [
|
|
||||||
{
|
|
||||||
kty: publicKey.kty!,
|
|
||||||
crv: publicKey.crv!,
|
|
||||||
x: publicKey.x!,
|
|
||||||
y: publicKey.y!,
|
|
||||||
use: 'sig',
|
|
||||||
kid: 'ai-card-oauth-key-1',
|
|
||||||
alg: 'ES256'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.jwks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign a JWT with the private key
|
|
||||||
*/
|
|
||||||
static async signJWT(header: any, payload: any): Promise<string> {
|
|
||||||
const keyPair = await this.getKeyPair();
|
|
||||||
|
|
||||||
const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
|
|
||||||
const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
|
|
||||||
const message = `${headerB64}.${payloadB64}`;
|
|
||||||
|
|
||||||
const signature = await window.crypto.subtle.sign(
|
|
||||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
||||||
keyPair.privateKey,
|
|
||||||
new TextEncoder().encode(message)
|
|
||||||
);
|
|
||||||
|
|
||||||
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, '');
|
|
||||||
|
|
||||||
return `${message}.${signatureB64}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> {
|
|
||||||
try {
|
|
||||||
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
||||||
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
|
|
||||||
const privateKey = await window.crypto.subtle.importKey(
|
|
||||||
'jwk',
|
|
||||||
keyData,
|
|
||||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
||||||
true,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derive public key from private key
|
|
||||||
const publicKeyData = { ...keyData };
|
|
||||||
delete publicKeyData.d; // Remove private component
|
|
||||||
|
|
||||||
const publicKey = await window.crypto.subtle.importKey(
|
|
||||||
'jwk',
|
|
||||||
publicKeyData,
|
|
||||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
||||||
true,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
return { privateKey, publicKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear stored keys (for testing/reset)
|
|
||||||
*/
|
|
||||||
static clearKeys(): void {
|
|
||||||
localStorage.removeItem('oauth_private_key');
|
|
||||||
this.keyPair = null;
|
|
||||||
this.jwks = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate dynamic client metadata based on current URL
|
|
||||||
*/
|
|
||||||
export function generateClientMetadata(): any {
|
|
||||||
// Use environment variables if available, fallback to current origin
|
|
||||||
const host = import.meta.env.VITE_APP_HOST || window.location.origin;
|
|
||||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
|
|
||||||
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
client_id: clientId,
|
|
||||||
client_name: 'ai.card',
|
|
||||||
client_uri: host,
|
|
||||||
logo_uri: `${host}/favicon.ico`,
|
|
||||||
tos_uri: `${host}/terms`,
|
|
||||||
policy_uri: `${host}/privacy`,
|
|
||||||
redirect_uris: [redirectUri, host],
|
|
||||||
response_types: ['code'],
|
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
|
||||||
token_endpoint_auth_method: 'private_key_jwt',
|
|
||||||
token_endpoint_auth_signing_alg: 'ES256',
|
|
||||||
scope: 'atproto transition:generic',
|
|
||||||
subject_type: 'public',
|
|
||||||
application_type: 'web',
|
|
||||||
dpop_bound_access_tokens: true,
|
|
||||||
jwks_uri: `${host}/.well-known/jwks.json`
|
|
||||||
};
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user