Compare commits
222 Commits
v0.1.5
...
rebuild-mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
b344a0a760
|
|||
|
d3f08e6db5
|
|||
|
68ac88045b
|
|||
|
bda6b9700d
|
|||
|
591edf61f8
|
|||
|
14b7ccd391
|
|||
|
e577754298
|
|||
|
b5914cade5
|
|||
|
a07c77d3f1
|
|||
|
1700f41e4f
|
|||
|
cf2708c7f3
|
|||
|
db29524307
|
|||
|
e5aeffb621
|
|||
|
cbf3c424e3
|
|||
|
adf2121a8b
|
|||
|
5ba0b77154
|
|||
|
22fda9cb2d
|
|||
|
b0ca3f9163
|
|||
|
08c4a5cd52
|
|||
|
c004905d39
|
|||
|
a970bc008f
|
|||
|
678c238ee7
|
|||
|
9f9fabd478
|
|||
|
acf9cacda0
|
|||
|
a88a61f866
|
|||
|
afd95636da
|
|||
|
62ecab5f04
|
|||
|
d46502d177
|
|||
|
0d742ca1f2
|
|||
|
0e820f0746
|
|||
|
896734e265
|
|||
|
9535e7f08d
|
|||
|
2d8a565a34
|
|||
|
3109b99959
|
|||
|
2558fb32fa
|
|||
|
7b60de8322
|
|||
|
87d313575b
|
|||
|
6d133f2d34
|
|||
|
84d6f7ae25
|
|||
|
05df0dcb7d
|
|||
|
3f88533d2b
|
|||
|
44d85e1b00
|
|||
|
f642944550
|
|||
|
ccdfe9cedf
|
|||
|
6763336b2f
|
|||
|
0b594e9a2b
|
|||
|
281729460a
|
|||
|
8f0e714a05
|
|||
|
18898e4ed5
|
|||
|
1dc64b9110
|
|||
|
8074096447
|
|||
|
e4b330ae27
|
|||
|
026f7e963b
|
|||
|
9ecd293693
|
|||
|
e41f007e8e
|
|||
|
01012424e8
|
|||
|
f26717c50d
|
|||
|
edce421464
|
|||
|
eaf332e208
|
|||
|
0a79bcf73e
|
|||
|
eb5c7a35d0
|
|||
|
ab7dfd90c5
|
|||
|
1835688595
|
|||
|
0135f6cb7a
|
|||
|
d448278989
|
|||
|
837ff28a3d
|
|||
|
99a8e4e6bd
|
|||
|
bd5ef0809c
|
|||
|
3d7021b446
|
|||
|
297381bc0f
|
|||
|
00e0b301ce
|
|||
|
331d11f319
|
|||
|
c4bb4f1f0f
|
|||
|
9ff70eb1b0
|
|||
|
abfdf1d016
|
|||
|
a9338e13a6
|
|||
|
96da3eb52b
|
|||
|
d4a2ea4e24
|
|||
|
85c49d4657
|
|||
|
8b61f2fa72
|
|||
|
92d5c0da32
|
|||
|
6a8e160540
|
|||
|
9e31a0fb0a
|
|||
|
1c6cf7d063
|
|||
|
dd2e3b4fb9
|
|||
|
976107903b
|
|||
|
2aa6616209
|
|||
|
a2ec41bf87
|
|||
|
3af175f4ed
|
|||
|
0e1dff12c2
|
|||
|
5708e9c53d
|
|||
|
824aee7b74
|
|||
|
aff1b3a7bd
|
|||
|
fa524138c6
|
|||
|
6047fa4161
|
|||
|
c5a70a433b
|
|||
|
0d90ba21e0
|
|||
|
e1eab122c8
|
|||
|
c3cb3db680
|
|||
|
55745ff051
|
|||
|
5aeeba106a
|
|||
|
f1e76ab31f
|
|||
|
3c9ef78696
|
|||
|
ee2d21b0f3
|
|||
|
0667ac58fb
|
|||
|
d89855338b
|
|||
|
e19170cdff
|
|||
|
c3e22611f5
|
|||
|
2943c94ec1
|
|||
|
f27997b7e8
|
|||
|
447e4bded9
|
|||
|
03161a52ca
|
|||
|
fe9381a860
|
|||
|
f0cea89005
|
|||
|
b059fe1de0
|
|||
|
07b0b0f702
|
|||
|
ecd69557fe
|
|||
|
452a0fda6a
|
|||
|
a62dd82790
|
|||
|
3faec33bac
|
|||
|
33402f4a21
|
|||
|
3e65bc8210
|
|||
|
16d724ec25
|
|||
|
69182a1bf8
|
|||
|
0110773592
|
|||
|
75f108e7b8
|
|||
|
263189ce72
|
|||
|
7800a655f3
|
|||
|
76c797e4d8
|
|||
|
d1a1c92842
|
|||
|
9da1f87640
|
|||
|
ddfc43512c
|
|||
|
b3ccd61935
|
|||
|
a243b6a44e
|
|||
|
e3c1cf4790
|
|||
|
a6236661bf
|
|||
|
195a4474c9
|
|||
|
4a34a6ca59
|
|||
|
4d01fb8507
|
|||
|
d69c9aa09b
|
|||
|
99ee49f76e
|
|||
|
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
|
|||
|
b17ac3d91a
|
|||
|
81f87d0462
|
|||
|
a020fa24d8
|
|||
|
21c53010b7
|
|||
|
4f7834f85c
|
|||
|
fecd927b91
|
|||
|
b54e8089ea
|
|||
|
174cb12d4d
|
|||
|
a1186f8185
|
|||
|
833549756b
|
|||
|
4edde5293a
|
|||
|
f0fdf678c8
|
|||
|
820e47f634
|
|||
|
4dac4a83e0
|
|||
|
fccf75949c
|
|||
|
6600a9e0cf
|
|||
|
0d79af5aa5
|
|||
|
db04af76ab
|
|||
|
5f0b09b555
|
|||
|
8fa9e474d1
|
|||
|
5339dd28b0
|
|||
|
1e83b50e3f
|
|||
|
889ce8baa1
|
|||
|
286b46c6e6
|
|||
|
b780d27ace
|
|||
|
831fcb7865
|
|||
|
3f8bbff7c2
|
|||
|
5cb73a9ed3
|
|||
|
6ce8d44c4b
|
|||
|
167cfb35f7
|
|||
|
c8377ceabf
|
|||
|
e917c563f2
|
|||
|
a76933c23b
|
|||
|
8d960b7a40
|
|||
|
d3967c782f
|
|||
|
63b6fd5142
|
|||
|
27935324c7
|
|||
|
594d7e7aef
|
|||
|
be86c11e74
|
|||
|
619675b551
|
|||
|
d4d98e2e91
|
|||
|
8dac463345
|
@@ -1,54 +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:*)"
|
||||
],
|
||||
"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
|
||||
82
.github/workflows/cloudflare-pages.yml
vendored
82
.github/workflows/cloudflare-pages.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
OAUTH_DIR: oauth
|
||||
KEEP_DEPLOYMENTS: 5
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,34 +24,41 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '21'
|
||||
node-version: '25'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd oauth
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
npm install
|
||||
|
||||
- name: Build OAuth app
|
||||
run: |
|
||||
cd oauth
|
||||
npm run build
|
||||
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
NODE_ENV=production npm run build
|
||||
- name: Copy OAuth build to static
|
||||
run: |
|
||||
# Remove old assets (following run.zsh pattern)
|
||||
rm -rf my-blog/static/assets
|
||||
# Copy all dist files to static
|
||||
cp -rf oauth/dist/* my-blog/static/
|
||||
# Copy index.html to oauth-assets.html template
|
||||
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
|
||||
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||
|
||||
- name: Build PDS app
|
||||
run: |
|
||||
cd pds
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Copy PDS build to static
|
||||
run: |
|
||||
rm -rf my-blog/static/pds
|
||||
cp -rf pds/dist my-blog/static/pds
|
||||
|
||||
- name: Cache ailog binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ./bin
|
||||
key: ailog-bin-${{ runner.os }}
|
||||
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
|
||||
restore-keys: |
|
||||
ailog-bin-${{ runner.os }}
|
||||
ailog-bin-${{ runner.os }}-v
|
||||
|
||||
- name: Setup ailog binary
|
||||
run: |
|
||||
@@ -109,3 +120,50 @@ jobs:
|
||||
directory: my-blog/public
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }} } } }"
|
||||
# Get all deployments
|
||||
DEPLOYMENTS=$(curl -s -X GET \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Extract deployment IDs (skip the latest N deployments)
|
||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
|
||||
|
||||
if [ -z "$DEPLOYMENT_IDS" ]; then
|
||||
echo "No old deployments to delete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Delete old deployments
|
||||
for ID in $DEPLOYMENT_IDS; do
|
||||
echo "Deleting deployment: $ID"
|
||||
RESPONSE=$(curl -s -X DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||
if [ "$SUCCESS" = "true" ]; then
|
||||
echo "Successfully deleted deployment: $ID"
|
||||
else
|
||||
echo "Failed to delete deployment: $ID"
|
||||
echo "$RESPONSE" | jq .
|
||||
fi
|
||||
|
||||
sleep 1 # Rate limiting
|
||||
done
|
||||
|
||||
echo "Cleanup completed!"
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,14 +1,12 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
/public
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
/dist
|
||||
/repos
|
||||
/pds/dist
|
||||
.DS_Store
|
||||
cloudflare-config.yml
|
||||
my-blog/public/
|
||||
dist
|
||||
.config
|
||||
.claude
|
||||
node_modules
|
||||
package-lock.json
|
||||
my-blog/static/assets/comment-atproto-*
|
||||
bin/ailog
|
||||
claude.md
|
||||
|
||||
73
Cargo.toml
73
Cargo.toml
@@ -1,80 +1,21 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.1.5"
|
||||
version = "0.3.5"
|
||||
edition = "2021"
|
||||
authors = ["syui"]
|
||||
description = "A static blog generator with AI features"
|
||||
description = "static site generator for atproto"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "ailog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
pulldown-cmark = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
|
||||
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs"] }
|
||||
anyhow = "1.0"
|
||||
toml = "0.8"
|
||||
chrono = "0.4"
|
||||
tera = "1.20"
|
||||
walkdir = "2.5"
|
||||
gray_matter = "0.2"
|
||||
fs_extra = "1.3"
|
||||
colored = "2.1"
|
||||
serde_yaml = "0.9"
|
||||
syntect = "5.2"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
urlencoding = "2.1"
|
||||
dirs = "5.0"
|
||||
chrono = "0.4"
|
||||
walkdir = "2.5"
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
tower-sessions = "0.12"
|
||||
jsonwebtoken = "9.2"
|
||||
cookie = "0.18"
|
||||
# Documentation generation dependencies
|
||||
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
|
||||
quote = "1.0"
|
||||
ignore = "0.4"
|
||||
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
|
||||
regex = "1.0"
|
||||
# ATProto and stream monitoring dependencies
|
||||
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
|
||||
futures-util = "0.3"
|
||||
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.14"
|
||||
|
||||
[profile.dev]
|
||||
# Speed up development builds
|
||||
opt-level = 0
|
||||
debug = true
|
||||
debug-assertions = true
|
||||
overflow-checks = true
|
||||
lto = false
|
||||
panic = 'unwind'
|
||||
incremental = true
|
||||
codegen-units = 256
|
||||
|
||||
[profile.release]
|
||||
# Optimize release builds for speed and size
|
||||
opt-level = 3
|
||||
debug = false
|
||||
debug-assertions = false
|
||||
overflow-checks = false
|
||||
lto = true
|
||||
panic = 'abort'
|
||||
incremental = false
|
||||
codegen-units = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
# Optimize dependencies in dev builds
|
||||
opt-level = 3
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,32 +0,0 @@
|
||||
# Multi-stage build for ailog
|
||||
FROM rust:1.75 as builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
|
||||
|
||||
# Copy blog content
|
||||
COPY my-blog ./blog
|
||||
|
||||
# Build static site
|
||||
RUN ailog build blog
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run server
|
||||
CMD ["ailog", "serve", "blog"]
|
||||
559
README.md
559
README.md
@@ -1,559 +1,4 @@
|
||||
# ai.log
|
||||
# ailog
|
||||
|
||||
AI-powered static blog generator with ATProto integration, part of the ai.ai ecosystem.
|
||||
`bundle: ai.syui.log`
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation & Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
git clone https://git.syui.ai/ai/log
|
||||
cd log
|
||||
|
||||
# 2. Build ailog
|
||||
cargo build --release
|
||||
|
||||
# 3. Initialize blog
|
||||
./target/release/ailog init my-blog
|
||||
|
||||
# 4. Create your first post
|
||||
./target/release/ailog new "My First Post"
|
||||
|
||||
# 5. Build static site
|
||||
./target/release/ailog build
|
||||
|
||||
# 6. Serve locally
|
||||
./target/release/ailog serve
|
||||
```
|
||||
|
||||
### Install via Cargo
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
# Now you can use `ailog` command globally
|
||||
```
|
||||
|
||||
## 📖 Core Commands
|
||||
|
||||
### Blog Management
|
||||
|
||||
```bash
|
||||
# Project setup
|
||||
ailog init <project-name> # Initialize new blog project
|
||||
ailog new <title> # Create new blog post
|
||||
ailog build # Generate static site with JSON index
|
||||
ailog serve # Start development server
|
||||
ailog clean # Clean build artifacts
|
||||
|
||||
# ATProto authentication
|
||||
ailog auth init # Setup ATProto credentials
|
||||
ailog auth status # Check authentication status
|
||||
ailog auth logout # Clear credentials
|
||||
|
||||
# OAuth app build
|
||||
ailog oauth build <project-dir> # Build OAuth comment system
|
||||
```
|
||||
|
||||
### Stream & AI Features
|
||||
|
||||
```bash
|
||||
# Start monitoring & AI generation
|
||||
ailog stream start --ai-generate # Monitor blog + auto-generate AI content
|
||||
ailog stream start --daemon # Run as background daemon
|
||||
ailog stream status # Check stream status
|
||||
ailog stream stop # Stop monitoring
|
||||
ailog stream test # Test ATProto API access
|
||||
```
|
||||
|
||||
### Documentation & Translation
|
||||
|
||||
```bash
|
||||
# Generate documentation
|
||||
ailog doc readme --with-ai # Generate enhanced README
|
||||
ailog doc api --output ./docs # Generate API documentation
|
||||
ailog doc structure --include-deps # Analyze project structure
|
||||
|
||||
# AI-powered translation
|
||||
ailog doc translate --input README.md --target-lang en
|
||||
ailog doc translate --input docs/guide.ja.md --target-lang en --model qwen2.5:latest
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
ai.log/
|
||||
├── src/ # Rust static blog generator
|
||||
│ ├── commands/ # CLI command implementations
|
||||
│ ├── generator.rs # Core blog generation + JSON index
|
||||
│ ├── mcp/ # MCP server integration
|
||||
│ └── main.rs # CLI entry point
|
||||
├── my-blog/ # Your blog content
|
||||
│ ├── content/posts/ # Markdown blog posts
|
||||
│ ├── templates/ # Tera templates
|
||||
│ ├── static/ # Static assets
|
||||
│ └── public/ # Generated site output
|
||||
├── oauth/ # ATProto comment system
|
||||
│ ├── src/ # TypeScript OAuth app
|
||||
│ ├── dist/ # Built OAuth assets
|
||||
│ └── package.json # Node.js dependencies
|
||||
└── target/ # Rust build output
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Blog Posts (Markdown) → ailog build → public/
|
||||
├── Static HTML pages
|
||||
└── index.json (API)
|
||||
↓
|
||||
ailog stream start --ai-generate → Monitor index.json
|
||||
↓
|
||||
New posts detected → Ollama AI → ATProto records
|
||||
├── ai.syui.log.chat.lang (translations)
|
||||
└── ai.syui.log.chat.comment (AI comments)
|
||||
↓
|
||||
OAuth app → Display AI-generated content
|
||||
```
|
||||
|
||||
## 🤖 AI Integration
|
||||
|
||||
### AI Content Generation
|
||||
|
||||
The `--ai-generate` flag enables automatic AI content generation:
|
||||
|
||||
1. **Blog Monitoring**: Monitors `index.json` every 5 minutes
|
||||
2. **Duplicate Prevention**: Checks existing ATProto collections
|
||||
3. **AI Generation**: Uses Ollama (gemma3:4b) for translations & comments
|
||||
4. **ATProto Storage**: Saves to derived collections (`base.chat.lang`, `base.chat.comment`)
|
||||
|
||||
```bash
|
||||
# Start AI generation monitor
|
||||
ailog stream start --ai-generate
|
||||
|
||||
# Output:
|
||||
# 🤖 Starting AI content generation monitor...
|
||||
# 📡 Blog host: https://syui.ai
|
||||
# 🧠 Ollama host: https://ollama.syui.ai
|
||||
# 🔍 Checking for new blog posts...
|
||||
# ✅ Generated translation for: 静的サイトジェネレータを作った
|
||||
# ✅ Generated comment for: 静的サイトジェネレータを作った
|
||||
```
|
||||
|
||||
### Collection Management
|
||||
|
||||
ailog uses a **simplified collection structure** based on a single base collection name:
|
||||
|
||||
```bash
|
||||
# Single environment variable controls all collections (unified naming)
|
||||
export VITE_OAUTH_COLLECTION="ai.syui.log"
|
||||
|
||||
# Automatically derives:
|
||||
# - ai.syui.log (comments)
|
||||
# - ai.syui.log.user (user management)
|
||||
# - ai.syui.log.chat.lang (AI translations)
|
||||
# - ai.syui.log.chat.comment (AI comments)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ **Simple**: One variable instead of 5+
|
||||
- ✅ **Consistent**: All collections follow the same pattern
|
||||
- ✅ **Manageable**: Easy systemd/production configuration
|
||||
|
||||
### Ask AI Feature
|
||||
|
||||
Interactive AI chat integrated into blog pages:
|
||||
|
||||
```bash
|
||||
# 1. Setup Ollama
|
||||
brew install ollama
|
||||
ollama pull gemma2:2b
|
||||
|
||||
# 2. Start with CORS support
|
||||
OLLAMA_ORIGINS="https://example.com" ollama serve
|
||||
|
||||
# 3. Configure AI DID in templates/base.html
|
||||
const aiConfig = {
|
||||
systemPrompt: 'You are a helpful AI assistant.',
|
||||
aiDid: 'did:plc:your-ai-bot-did'
|
||||
};
|
||||
```
|
||||
|
||||
## 🌐 ATProto Integration
|
||||
|
||||
### OAuth Comment System
|
||||
|
||||
The OAuth app provides ATProto-authenticated commenting:
|
||||
|
||||
```bash
|
||||
# 1. Build OAuth app
|
||||
cd oauth
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# 2. Configure for production
|
||||
ailog oauth build my-blog # Auto-generates .env.production
|
||||
|
||||
# 3. Deploy OAuth assets
|
||||
# Assets are automatically copied to public/ during ailog build
|
||||
```
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
```bash
|
||||
# Initialize ATProto authentication
|
||||
ailog auth init
|
||||
|
||||
# Input required:
|
||||
# - Handle (e.g., your.handle.bsky.social)
|
||||
# - Access JWT
|
||||
# - Refresh JWT
|
||||
|
||||
# Check status
|
||||
ailog auth status
|
||||
```
|
||||
|
||||
### Collection Structure
|
||||
|
||||
All ATProto collections are **automatically derived** from a single base name:
|
||||
|
||||
```
|
||||
Base Collection: "ai.syui.log"
|
||||
├── ai.syui.log (user comments)
|
||||
├── ai.syui.log.user (registered commenters)
|
||||
└── ai.syui.log.chat/
|
||||
├── ai.syui.log.chat.lang (AI translations)
|
||||
└── ai.syui.log.chat.comment (AI comments)
|
||||
```
|
||||
|
||||
**Configuration Priority:**
|
||||
1. Environment variable: `VITE_OAUTH_COLLECTION` (unified)
|
||||
2. config.toml: `[oauth] collection = "..."`
|
||||
3. Auto-generated from domain (e.g., `log.syui.ai` → `ai.syui.log`)
|
||||
4. Default: `ai.syui.log`
|
||||
|
||||
### Stream Monitoring
|
||||
|
||||
```bash
|
||||
# Monitor ATProto streams for comments
|
||||
ailog stream start
|
||||
|
||||
# Enable AI generation alongside monitoring
|
||||
ailog stream start --ai-generate --daemon
|
||||
```
|
||||
|
||||
## 📱 OAuth App Features
|
||||
|
||||
The OAuth TypeScript app provides:
|
||||
|
||||
### Comment System
|
||||
- **Real-time Comments**: ATProto-authenticated commenting
|
||||
- **User Management**: Automatic user registration
|
||||
- **Mobile Responsive**: Optimized for all devices
|
||||
- **JSON View**: Technical record inspection
|
||||
|
||||
### AI Content Display
|
||||
- **Lang: EN Tab**: AI-generated English translations
|
||||
- **AI Comment Tab**: AI-generated blog insights
|
||||
- **Admin Records**: Fetches from admin DID collections
|
||||
- **Real-time Updates**: Live content refresh
|
||||
|
||||
### Setup & Configuration
|
||||
|
||||
```bash
|
||||
cd oauth
|
||||
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Preview production
|
||||
npm run preview
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
# Production (.env.production - auto-generated by ailog oauth build)
|
||||
VITE_APP_HOST=https://syui.ai
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
|
||||
# Simplified collection configuration (single base collection)
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
# ... (other AI settings)
|
||||
```
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### JSON Index Generation
|
||||
|
||||
Every `ailog build` generates `/public/index.json`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "静的サイトジェネレータを作った",
|
||||
"href": "https://syui.ai/posts/2025-06-06-ailog.html",
|
||||
"formated_time": "Thu Jun 12, 2025",
|
||||
"utc_time": "2025-06-12T00:00:00Z",
|
||||
"tags": ["blog", "rust", "mcp", "atp"],
|
||||
"contents": "Plain text content...",
|
||||
"description": "Excerpt...",
|
||||
"categories": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **API Access**: Programmatic blog content access
|
||||
- **Stream Monitoring**: AI generation triggers
|
||||
- **Search Integration**: Full-text search capabilities
|
||||
|
||||
### Translation System
|
||||
|
||||
AI-powered document translation with Ollama:
|
||||
|
||||
```bash
|
||||
# Basic translation
|
||||
ailog doc translate --input README.md --target-lang en
|
||||
|
||||
# Advanced options
|
||||
ailog doc translate \
|
||||
--input docs/guide.ja.md \
|
||||
--target-lang en \
|
||||
--source-lang ja \
|
||||
--model qwen2.5:latest \
|
||||
--output docs/guide.en.md
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Markdown-aware**: Preserves code blocks, links, tables
|
||||
- **Multiple models**: qwen2.5, gemma3, etc.
|
||||
- **Auto-detection**: Detects Japanese content automatically
|
||||
- **Structure preservation**: Maintains document formatting
|
||||
|
||||
### MCP Server Integration
|
||||
|
||||
```bash
|
||||
# Start MCP server for ai.gpt integration
|
||||
ailog mcp --port 8002
|
||||
|
||||
# Available tools:
|
||||
# - create_blog_post
|
||||
# - list_blog_posts
|
||||
# - build_blog
|
||||
# - get_post_content
|
||||
# - translate_document
|
||||
# - generate_documentation
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Deploy ai.log Blog
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Build ailog
|
||||
run: cargo build --release
|
||||
|
||||
- name: Build blog
|
||||
run: |
|
||||
cd my-blog
|
||||
../target/release/ailog build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: my-blog
|
||||
directory: my-blog/public
|
||||
```
|
||||
|
||||
### Production Setup
|
||||
|
||||
```bash
|
||||
# 1. Build for production
|
||||
cargo build --release
|
||||
|
||||
# 2. Setup systemd services
|
||||
sudo cp systemd/system/ailog-stream.service /etc/systemd/system/
|
||||
sudo systemctl enable ailog-stream.service
|
||||
sudo systemctl start ailog-stream.service
|
||||
|
||||
# 3. Configure Ollama with CORS
|
||||
sudo vim /usr/lib/systemd/system/ollama.service
|
||||
# Add: Environment="OLLAMA_ORIGINS=https://yourdomain.com"
|
||||
|
||||
# 4. Monitor services
|
||||
journalctl -u ailog-stream.service -f
|
||||
```
|
||||
|
||||
## 🌍 Translation Support
|
||||
|
||||
### Supported Languages
|
||||
|
||||
| Language | Code | Status | Model |
|
||||
|----------|------|--------|-------|
|
||||
| English | `en` | ✅ Full | qwen2.5 |
|
||||
| Japanese | `ja` | ✅ Full | qwen2.5 |
|
||||
| Chinese | `zh` | ✅ Full | qwen2.5 |
|
||||
| Korean | `ko` | ⚠️ Basic | qwen2.5 |
|
||||
| Spanish | `es` | ⚠️ Basic | qwen2.5 |
|
||||
|
||||
### Translation Workflow
|
||||
|
||||
1. **Parse**: Analyze markdown structure
|
||||
2. **Preserve**: Isolate code blocks and technical content
|
||||
3. **Translate**: Process with Ollama AI
|
||||
4. **Reconstruct**: Rebuild with original formatting
|
||||
5. **Validate**: Ensure structural integrity
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
### Personal Blog
|
||||
- **AI-Enhanced**: Automatic translations and AI insights
|
||||
- **Distributed Comments**: ATProto-based social interaction
|
||||
- **Mobile-First**: Responsive OAuth comment system
|
||||
|
||||
### Technical Documentation
|
||||
- **Code Analysis**: Automatic API documentation
|
||||
- **Multi-language**: AI-powered translation
|
||||
- **Structure Analysis**: Project overview generation
|
||||
|
||||
### AI Ecosystem Integration
|
||||
- **ai.gpt Connection**: Memory-driven content generation
|
||||
- **MCP Integration**: Claude Code workflow support
|
||||
- **Distributed Identity**: ATProto authentication
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Build Issues
|
||||
```bash
|
||||
# Check Rust version
|
||||
rustc --version
|
||||
|
||||
# Update dependencies
|
||||
cargo update
|
||||
|
||||
# Clean build
|
||||
cargo clean && cargo build --release
|
||||
```
|
||||
|
||||
### Authentication Problems
|
||||
```bash
|
||||
# Reset authentication
|
||||
ailog auth logout
|
||||
ailog auth init
|
||||
|
||||
# Test API access
|
||||
ailog stream test
|
||||
```
|
||||
|
||||
### AI Generation Issues
|
||||
```bash
|
||||
# Check Ollama status
|
||||
curl http://localhost:11434/api/tags
|
||||
|
||||
# Test with manual request
|
||||
curl -X POST http://localhost:11434/api/generate \
|
||||
-d '{"model":"gemma3:4b","prompt":"Test","stream":false}'
|
||||
|
||||
# Check CORS settings
|
||||
# Ensure OLLAMA_ORIGINS includes your domain
|
||||
```
|
||||
|
||||
### OAuth App Issues
|
||||
```bash
|
||||
# Rebuild OAuth assets
|
||||
cd oauth
|
||||
rm -rf dist/
|
||||
npm run build
|
||||
|
||||
# Check environment variables
|
||||
cat .env.production
|
||||
|
||||
# Verify client-metadata.json
|
||||
curl https://yourdomain.com/client-metadata.json
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Core Concepts
|
||||
- **Static Generation**: Rust-powered site building
|
||||
- **JSON Index**: API-compatible blog data
|
||||
- **ATProto Integration**: Distributed social features
|
||||
- **AI Enhancement**: Automatic content generation
|
||||
|
||||
### File Structure
|
||||
- `config.toml`: Blog configuration (simplified collection setup)
|
||||
- `content/posts/*.md`: Blog post sources
|
||||
- `templates/*.html`: Tera template files
|
||||
- `public/`: Generated static site + API (index.json)
|
||||
- `oauth/dist/`: Built OAuth assets
|
||||
|
||||
### Example config.toml
|
||||
```toml
|
||||
[site]
|
||||
title = "My Blog"
|
||||
base_url = "https://myblog.com"
|
||||
|
||||
[oauth]
|
||||
admin = "did:plc:your-admin-did"
|
||||
collection = "ai.myblog.log" # Single base collection
|
||||
|
||||
[ai]
|
||||
enabled = true
|
||||
auto_translate = true
|
||||
comment_moderation = true
|
||||
model = "gemma3:4b"
|
||||
host = "https://ollama.syui.ai"
|
||||
```
|
||||
|
||||
## 🔗 ai.ai Ecosystem
|
||||
|
||||
ai.log is part of the broader ai.ai ecosystem:
|
||||
|
||||
- **ai.gpt**: Memory system and AI integration
|
||||
- **ai.card**: ATProto-based card game system
|
||||
- **ai.bot**: Social media automation
|
||||
- **ai.verse**: 3D virtual world integration
|
||||
- **ai.shell**: AI-powered shell interface
|
||||
|
||||
### yui System Compliance
|
||||
- **Uniqueness**: Each blog tied to individual identity
|
||||
- **Reality Reflection**: Personal memories → digital content
|
||||
- **Irreversibility**: Published content maintains integrity
|
||||
|
||||
## 📝 License
|
||||
|
||||
© syui
|
||||
|
||||
---
|
||||
|
||||
**Part of the ai ecosystem**: ai.gpt, ai.card, ai.log, ai.bot, ai.verse, ai.shell
|
||||
128
action.yml
128
action.yml
@@ -1,128 +0,0 @@
|
||||
name: 'ailog Static Site Generator'
|
||||
description: 'AI-powered static blog generator with atproto integration'
|
||||
author: 'syui'
|
||||
|
||||
branding:
|
||||
icon: 'book-open'
|
||||
color: 'orange'
|
||||
|
||||
inputs:
|
||||
content-dir:
|
||||
description: 'Content directory containing markdown files'
|
||||
required: false
|
||||
default: 'content'
|
||||
output-dir:
|
||||
description: 'Output directory for generated site'
|
||||
required: false
|
||||
default: 'public'
|
||||
template-dir:
|
||||
description: 'Template directory'
|
||||
required: false
|
||||
default: 'templates'
|
||||
static-dir:
|
||||
description: 'Static assets directory'
|
||||
required: false
|
||||
default: 'static'
|
||||
config-file:
|
||||
description: 'Configuration file path'
|
||||
required: false
|
||||
default: 'ailog.toml'
|
||||
ai-integration:
|
||||
description: 'Enable AI features'
|
||||
required: false
|
||||
default: 'true'
|
||||
atproto-integration:
|
||||
description: 'Enable atproto/OAuth features'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
outputs:
|
||||
site-url:
|
||||
description: 'Generated site URL'
|
||||
value: ${{ steps.generate.outputs.site-url }}
|
||||
build-time:
|
||||
description: 'Build time in seconds'
|
||||
value: ${{ steps.generate.outputs.build-time }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- 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
|
||||
shell: bash
|
||||
run: |
|
||||
# Check if pre-built binary exists
|
||||
if [ -f "./bin/ailog-linux-x86_64" ]; then
|
||||
echo "Using pre-built binary from repository"
|
||||
chmod +x ./bin/ailog-linux-x86_64
|
||||
CURRENT_VERSION=$(./bin/ailog-linux-x86_64 --version 2>/dev/null || echo "unknown")
|
||||
echo "Binary version: $CURRENT_VERSION"
|
||||
else
|
||||
echo "No pre-built binary found, trying to build from source..."
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
cargo build --release
|
||||
mkdir -p ./bin
|
||||
cp ./target/release/ailog ./bin/ailog-linux-x86_64
|
||||
echo "Built from source: $(./bin/ailog-linux-x86_64 --version 2>/dev/null)"
|
||||
else
|
||||
echo "Error: No binary found and cargo not available"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Setup Node.js for OAuth app
|
||||
if: ${{ inputs.atproto-integration == 'true' }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build OAuth app
|
||||
if: ${{ inputs.atproto-integration == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d "oauth" ]; then
|
||||
cd oauth
|
||||
npm install
|
||||
npm run build
|
||||
cp -r dist/* ../${{ inputs.static-dir }}/
|
||||
fi
|
||||
|
||||
- name: Generate site
|
||||
id: generate
|
||||
shell: bash
|
||||
run: |
|
||||
start_time=$(date +%s)
|
||||
|
||||
# Change to blog directory and run build
|
||||
# Note: ailog build only takes a path argument, not options
|
||||
if [ -d "my-blog" ]; then
|
||||
cd my-blog
|
||||
../bin/ailog-linux-x86_64 build
|
||||
else
|
||||
# If no my-blog directory, use current directory
|
||||
./bin/ailog-linux-x86_64 build .
|
||||
fi
|
||||
|
||||
end_time=$(date +%s)
|
||||
build_time=$((end_time - start_time))
|
||||
|
||||
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
|
||||
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Display build summary
|
||||
shell: bash
|
||||
run: |
|
||||
echo "✅ ailog build completed successfully"
|
||||
echo "📁 Output directory: ${{ inputs.output-dir }}"
|
||||
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
|
||||
if [ -d "${{ inputs.output-dir }}" ]; then
|
||||
echo "📄 Generated files:"
|
||||
find ${{ inputs.output-dir }} -type f | head -10
|
||||
fi
|
||||
@@ -1 +0,0 @@
|
||||
あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。
|
||||
@@ -1,173 +0,0 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Generate AI content for blog posts
|
||||
# Usage: ./bin/ailog-generate.zsh [md-file]
|
||||
|
||||
set -e
|
||||
|
||||
# Load configuration
|
||||
f=~/.config/syui/ai/bot/token.json
|
||||
|
||||
# Default values
|
||||
default_pds="bsky.social"
|
||||
default_did=`cat $f|jq -r .did`
|
||||
default_token=`cat $f|jq -r .accessJwt`
|
||||
default_refresh=`cat $f|jq -r .refreshJwt`
|
||||
|
||||
# Refresh token if needed
|
||||
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
|
||||
default_token=`cat $f|jq -r .accessJwt`
|
||||
|
||||
# Set variables
|
||||
admin_did=$default_did
|
||||
admin_token=$default_token
|
||||
ai_did="did:plc:4hqjfn7m6n5hno3doamuhgef"
|
||||
ollama_host="https://ollama.syui.ai"
|
||||
blog_host="https://syui.ai"
|
||||
pds=$default_pds
|
||||
|
||||
# Parse arguments
|
||||
md_file=$1
|
||||
|
||||
# Function to generate content using Ollama
|
||||
generate_ai_content() {
|
||||
local content=$1
|
||||
local prompt_type=$2
|
||||
local model="gemma3:4b"
|
||||
|
||||
case $prompt_type in
|
||||
"translate")
|
||||
prompt="Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n$content"
|
||||
;;
|
||||
"comment")
|
||||
prompt="Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n$content"
|
||||
;;
|
||||
esac
|
||||
|
||||
response=$(curl -sL -X POST "$ollama_host/api/generate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"$model\",
|
||||
\"prompt\": \"$prompt\",
|
||||
\"stream\": false,
|
||||
\"options\": {
|
||||
\"temperature\": 0.9,
|
||||
\"top_p\": 0.9,
|
||||
\"num_predict\": 500
|
||||
}
|
||||
}")
|
||||
|
||||
echo "$response" | jq -r '.response'
|
||||
}
|
||||
|
||||
# Function to put record to ATProto
|
||||
put_record() {
|
||||
local collection=$1
|
||||
local rkey=$2
|
||||
local record=$3
|
||||
|
||||
curl -sL -X POST "https://$pds/xrpc/com.atproto.repo.putRecord" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $admin_token" \
|
||||
-d "{
|
||||
\"repo\": \"$admin_did\",
|
||||
\"collection\": \"$collection\",
|
||||
\"rkey\": \"$rkey\",
|
||||
\"record\": $record
|
||||
}"
|
||||
}
|
||||
|
||||
# Function to process a single markdown file
|
||||
process_md_file() {
|
||||
local md_path=$1
|
||||
local filename=$(basename "$md_path" .md)
|
||||
local content=$(cat "$md_path")
|
||||
local post_url="$blog_host/posts/$filename"
|
||||
local rkey=$filename
|
||||
local now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
echo "Processing: $md_path"
|
||||
echo "Post URL: $post_url"
|
||||
|
||||
# Generate English translation
|
||||
echo "Generating English translation..."
|
||||
en_translation=$(generate_ai_content "$content" "translate")
|
||||
|
||||
if [ -n "$en_translation" ]; then
|
||||
lang_record="{
|
||||
\"\$type\": \"ai.syui.log.chat.lang\",
|
||||
\"type\": \"en\",
|
||||
\"body\": $(echo "$en_translation" | jq -Rs .),
|
||||
\"url\": \"$post_url\",
|
||||
\"createdAt\": \"$now\",
|
||||
\"author\": {
|
||||
\"did\": \"$ai_did\",
|
||||
\"handle\": \"yui.syui.ai\",
|
||||
\"displayName\": \"AI Translator\"
|
||||
}
|
||||
}"
|
||||
|
||||
echo "Saving translation to ATProto..."
|
||||
put_record "ai.syui.log.chat.lang" "$rkey" "$lang_record"
|
||||
fi
|
||||
|
||||
# Generate AI comment
|
||||
echo "Generating AI comment..."
|
||||
ai_comment=$(generate_ai_content "$content" "comment")
|
||||
|
||||
if [ -n "$ai_comment" ]; then
|
||||
comment_record="{
|
||||
\"\$type\": \"ai.syui.log.chat.comment\",
|
||||
\"type\": \"push\",
|
||||
\"body\": $(echo "$ai_comment" | jq -Rs .),
|
||||
\"url\": \"$post_url\",
|
||||
\"createdAt\": \"$now\",
|
||||
\"author\": {
|
||||
\"did\": \"$ai_did\",
|
||||
\"handle\": \"yui.syui.ai\",
|
||||
\"displayName\": \"AI Commenter\"
|
||||
}
|
||||
}"
|
||||
|
||||
echo "Saving comment to ATProto..."
|
||||
put_record "ai.syui.log.chat.comment" "$rkey" "$comment_record"
|
||||
fi
|
||||
|
||||
echo "Completed: $filename"
|
||||
echo
|
||||
}
|
||||
|
||||
# Main logic
|
||||
if [ -n "$md_file" ]; then
|
||||
# Process specific file
|
||||
if [ -f "$md_file" ]; then
|
||||
process_md_file "$md_file"
|
||||
else
|
||||
echo "Error: File not found: $md_file"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Process all new posts
|
||||
echo "Checking for posts without AI content..."
|
||||
|
||||
# Get existing records
|
||||
existing_langs=$(curl -sL "https://$pds/xrpc/com.atproto.repo.listRecords?repo=$admin_did&collection=ai.syui.log.chat.lang&limit=100" | jq -r '.records[]?.value.url' | sort | uniq)
|
||||
|
||||
# Process each markdown file
|
||||
for md in my-blog/content/posts/*.md; do
|
||||
if [ -f "$md" ]; then
|
||||
filename=$(basename "$md" .md)
|
||||
post_url="$blog_host/posts/$filename"
|
||||
|
||||
# Check if already processed
|
||||
if echo "$existing_langs" | grep -q "$post_url"; then
|
||||
echo "Skip (already processed): $filename"
|
||||
else
|
||||
process_md_file "$md"
|
||||
sleep 2 # Rate limiting
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "All done!"
|
||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
||||
#!/bin/zsh
|
||||
|
||||
#[collection] [pds] [did] [token]
|
||||
|
||||
set -e
|
||||
|
||||
f=~/.config/syui/ai/bot/token.json
|
||||
default_collection="ai.syui.log.chat"
|
||||
default_pds="bsky.social"
|
||||
default_did=`cat $f|jq -r .did`
|
||||
default_token=`cat $f|jq -r .accessJwt`
|
||||
default_refresh=`cat $f|jq -r .refreshJwt`
|
||||
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
|
||||
default_token=`cat $f|jq -r .accessJwt`
|
||||
collection=${1:-$default_collection}
|
||||
pds=${2:-$default_pds}
|
||||
did=${3:-$default_did}
|
||||
token=${4:-$default_token}
|
||||
|
||||
delete_record() {
|
||||
local rkey=$1
|
||||
local req="com.atproto.repo.deleteRecord"
|
||||
local url="https://$pds/xrpc/$req"
|
||||
local json="{\"collection\":\"$collection\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}"
|
||||
curl -sL -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-d "$json" \
|
||||
"$url"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo " ✓ Deleted: $rkey"
|
||||
else
|
||||
echo " ✗ Failed: $rkey"
|
||||
fi
|
||||
}
|
||||
|
||||
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$collection&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))
|
||||
for rkey in "${rkeys[@]}"; do
|
||||
echo $rkey
|
||||
delete_record $rkey
|
||||
done
|
||||
478
claude.md
478
claude.md
@@ -1,478 +0,0 @@
|
||||
# エコシステム統合設計書
|
||||
|
||||
## 中核思想
|
||||
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
|
||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
||||
- **現実の反映**: 現実→ゲーム→現実の循環的影響
|
||||
|
||||
## システム構成図
|
||||
|
||||
```
|
||||
存在子(ai) - 最小単位の意識
|
||||
↓
|
||||
[ai.moji] 文字システム
|
||||
↓
|
||||
[ai.os] + [ai.game device] ← 統合ハードウェア
|
||||
├── ai.shell (Claude Code的機能)
|
||||
├── ai.gpt (自律人格・記憶システム)
|
||||
├── ai.log (AIと連携するブログシステム)
|
||||
├── ai.ai (個人特化AI・心を読み取るAI)
|
||||
├── ai.card (カードゲーム・iOS/Web/API)
|
||||
└── ai.bot (分散SNS連携・カード配布)
|
||||
↓
|
||||
[ai.verse] メタバース
|
||||
├── world system (惑星型3D世界)
|
||||
├── at system (atproto/分散SNS)
|
||||
├── yui system (唯一性担保)
|
||||
└── ai system (存在属性)
|
||||
```
|
||||
|
||||
## 名前規則
|
||||
|
||||
名前規則は他のprojectと全て共通しています。exampleを示しますので、このルールに従ってください。
|
||||
|
||||
ここでは`ai.os`の場合の名前規則の例を記述します。
|
||||
|
||||
name: ai.os
|
||||
|
||||
- **[ "package", "code", "command" ]**: aios
|
||||
- **[ "dir", "url" ]**: ai/os
|
||||
- **[ "domain", "json" ]**: ai.os
|
||||
|
||||
```sh
|
||||
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.os
|
||||
{ "type": "os" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ai": {
|
||||
"os":{}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
他のprojectも同じ名前規則を採用します。`ai.gpt`ならpackageは`aigpt`です。
|
||||
|
||||
## config(設定ファイル, env, 環境依存)
|
||||
|
||||
`config`を置く場所は統一されており、各projectの名前規則の`dir`項目を使用します。例えば、aiosの場合は`~/.config/syui/ai/os/`以下となります。pythonなどを使用する場合、`python -m venv`などでこのpackage config dirに環境を構築して実行するようにしてください。
|
||||
|
||||
domain形式を採用して、私は各projectを`git.syui.ai/ai`にhostしていますから、`~/.config/syui/ai`とします。
|
||||
|
||||
```sh
|
||||
[syui.ai]
|
||||
syui/ai
|
||||
```
|
||||
|
||||
```sh
|
||||
# example
|
||||
~/.config/syui/ai
|
||||
├── card
|
||||
├── gpt
|
||||
├── os
|
||||
└── shell
|
||||
```
|
||||
|
||||
## 各システム詳細
|
||||
|
||||
### ai.gpt - 自律的送信AI
|
||||
**目的**: 関係性に基づく自発的コミュニケーション
|
||||
|
||||
**中核概念**:
|
||||
- **人格**: 記憶(過去の発話)と関係性パラメータで構成
|
||||
- **唯一性**: atproto accountとの1:1紐付け、改変不可能
|
||||
- **自律送信**: 関係性が閾値を超えると送信機能が解禁
|
||||
|
||||
**技術構成**:
|
||||
- `MemoryManager`: 完全ログ→AI要約→コア判定→選択的忘却
|
||||
- `RelationshipTracker`: 時間減衰・日次制限付き関係性スコア
|
||||
- `TransmissionController`: 閾値判定・送信トリガー
|
||||
- `Persona`: AI運勢(1-10ランダム)による人格変動
|
||||
|
||||
**実装仕様**:
|
||||
```
|
||||
- 言語: Python (fastapi_mcp)
|
||||
- ストレージ: JSON/SQLite選択式
|
||||
- インターフェース: Python CLI (click/typer)
|
||||
- スケジューリング: cron-like自律処理
|
||||
```
|
||||
|
||||
### ai.card - カードゲームシステム
|
||||
**目的**: atproto基盤でのユーザーデータ主権カードゲーム
|
||||
|
||||
**現在の状況**:
|
||||
- ai.botの機能として実装済み
|
||||
- atproto accountでmentionすると1日1回カードを取得
|
||||
- ai.api (MCP server予定) でユーザー管理
|
||||
|
||||
**移行計画**:
|
||||
- **iOS移植**: Claudeが担当予定
|
||||
- **データ保存**: atproto collection recordに保存(ユーザーがデータを所有)
|
||||
- **不正防止**: OAuth 2.1 scope (実装待ち) + MCP serverで対応
|
||||
- **画像ファイル**: Cloudflare Pagesが最適
|
||||
|
||||
**yui system適用**:
|
||||
- カードの効果がアカウント固有
|
||||
- 改ざん防止によるゲームバランス維持
|
||||
- 将来的にai.verseとの統合で固有スキルと連動
|
||||
|
||||
### ai.ai - 心を読み取るAI
|
||||
**目的**: 個人特化型AI・深層理解システム
|
||||
|
||||
**ai.gptとの関係**:
|
||||
- ai.gpt → ai.ai: 自律送信AIから心理分析AIへの連携
|
||||
- 関係性パラメータの深層分析
|
||||
- ユーザーの思想コア部分の特定支援
|
||||
|
||||
### ai.verse - UEメタバース
|
||||
**目的**: 現実反映型3D世界
|
||||
|
||||
**yui system実装**:
|
||||
- キャラクター ↔ プレイヤー 1:1紐付け
|
||||
- unique skill: そのプレイヤーのみ使用可能
|
||||
- 他プレイヤーは同キャラでも同スキル使用不可
|
||||
|
||||
**統合要素**:
|
||||
- ai.card: ゲーム内アイテムとしてのカード
|
||||
- ai.gpt: NPCとしての自律AI人格
|
||||
- atproto: ゲーム内プロフィール連携
|
||||
|
||||
## データフロー設計
|
||||
|
||||
### 唯一性担保の実装
|
||||
```
|
||||
現実の個人 → atproto account (DID) → ゲーム内avatar → 固有スキル
|
||||
↑_______________________________| (現実の反映)
|
||||
```
|
||||
|
||||
### AI駆動変換システム
|
||||
```
|
||||
遊び・創作活動 → ai.gpt分析 → 業務成果変換 → 企業価値創出
|
||||
↑________________________| (Play-to-Work)
|
||||
```
|
||||
|
||||
### カードゲーム・データ主権フロー
|
||||
```
|
||||
ユーザー → ai.bot mention → カード生成 → atproto collection → ユーザー所有
|
||||
↑ ↓
|
||||
← iOS app表示 ← ai.card API ←
|
||||
```
|
||||
|
||||
## 技術スタック統合
|
||||
|
||||
### Core Infrastructure
|
||||
- **OS**: Rust-based ai.os (Arch Linux base)
|
||||
- **Container**: Docker image distribution
|
||||
- **Identity**: atproto selfhost server + DID管理
|
||||
- **AI**: fastapi_mcp server architecture
|
||||
- **CLI**: Python unified (click/typer) - Rustから移行
|
||||
|
||||
### Game Engine Integration
|
||||
- **Engine**: Unreal Engine (Blueprint)
|
||||
- **Data**: atproto → UE → atproto sync
|
||||
- **Avatar**: 分散SNS profile → 3D character
|
||||
- **Streaming**: game screen = broadcast screen
|
||||
|
||||
### Mobile/Device
|
||||
- **iOS**: ai.card移植 (Claude担当)
|
||||
- **Hardware**: ai.game device (future)
|
||||
- **Interface**: controller-first design
|
||||
|
||||
## 実装優先順位
|
||||
|
||||
### Phase 1: AI基盤強化 (現在進行)
|
||||
- [ ] ai.gpt memory system完全実装
|
||||
- 記憶の階層化(完全ログ→要約→コア→忘却)
|
||||
- 関係性パラメータの時間減衰システム
|
||||
- AI運勢による人格変動機能
|
||||
- [ ] ai.card iOS移植
|
||||
- atproto collection record連携
|
||||
- MCP server化(ai.api刷新)
|
||||
- [ ] fastapi_mcp統一基盤構築
|
||||
|
||||
### Phase 2: ゲーム統合
|
||||
- [ ] ai.verse yui system実装
|
||||
- unique skill機能
|
||||
- atproto連携強化
|
||||
- [ ] ai.gpt ↔ ai.ai連携機能
|
||||
- [ ] 分散SNS ↔ ゲーム同期
|
||||
|
||||
### Phase 3: メタバース浸透
|
||||
- [ ] VTuber配信機能統合
|
||||
- [ ] Play-to-Work変換システム
|
||||
- [ ] ai.game device prototype
|
||||
|
||||
## 将来的な連携構想
|
||||
|
||||
### システム間連携(現在は独立実装)
|
||||
```
|
||||
ai.gpt (自律送信) ←→ ai.ai (心理分析)
|
||||
ai.card (iOS,Web,API) ←→ ai.verse (UEゲーム世界)
|
||||
```
|
||||
|
||||
**共通基盤**: fastapi_mcp
|
||||
**共通思想**: yui system(現実の反映・唯一性担保)
|
||||
|
||||
### データ改ざん防止戦略
|
||||
- **短期**: MCP serverによる検証
|
||||
- **中期**: OAuth 2.1 scope実装待ち
|
||||
- **長期**: ブロックチェーン的整合性チェック
|
||||
|
||||
## AIコミュニケーション最適化
|
||||
|
||||
### プロジェクト要件定義テンプレート
|
||||
```markdown
|
||||
# [プロジェクト名] 要件定義
|
||||
|
||||
## 哲学的背景
|
||||
- 存在子理論との関連:
|
||||
- yui system適用範囲:
|
||||
- 現実反映の仕組み:
|
||||
|
||||
## 技術要件
|
||||
- 使用技術(fastapi_mcp統一):
|
||||
- atproto連携方法:
|
||||
- データ永続化方法:
|
||||
|
||||
## ユーザーストーリー
|
||||
1. ユーザーが...すると
|
||||
2. システムが...を実行し
|
||||
3. 結果として...が実現される
|
||||
|
||||
## 成功指標
|
||||
- 技術的:
|
||||
- 哲学的(唯一性担保):
|
||||
```
|
||||
|
||||
### Claude Code活用戦略
|
||||
1. **小さく始める**: ai.gptのMCP機能拡張から
|
||||
2. **段階的統合**: 各システムを個別に完成させてから統合
|
||||
3. **哲学的一貫性**: 各実装でyui systemとの整合性を確認
|
||||
4. **現実反映**: 実装がどう現実とゲームを繋ぐかを常に明記
|
||||
|
||||
## 開発上の留意点
|
||||
|
||||
### MCP Server設計指針
|
||||
- 各AI(gpt, card, ai, bot)は独立したMCPサーバー
|
||||
- fastapi_mcp基盤で統一
|
||||
- atproto DIDによる認証・認可
|
||||
|
||||
### 記憶・データ管理
|
||||
- **ai.gpt**: 関係性の不可逆性重視
|
||||
- **ai.card**: ユーザーデータ主権重視
|
||||
- **ai.verse**: ゲーム世界の整合性重視
|
||||
|
||||
### 唯一性担保実装
|
||||
- atproto accountとの1:1紐付け必須
|
||||
- 改変不可能性をハッシュ・署名で保証
|
||||
- 他システムでの再現不可能性を技術的に実現
|
||||
|
||||
## 継続的改善
|
||||
- 各プロジェクトでこの設計書を参照
|
||||
- 新機能追加時はyui systemとの整合性をチェック
|
||||
- 他システムへの影響を事前評価
|
||||
- Claude Code導入時の段階的移行計画
|
||||
|
||||
## ai.gpt深層設計思想
|
||||
|
||||
### 人格の不可逆性
|
||||
- **関係性の破壊は修復不可能**: 現実の人間関係と同じ重み
|
||||
- **記憶の選択的忘却**: 重要でない情報は忘れるが、コア記憶は永続
|
||||
- **時間減衰**: すべてのパラメータは時間とともに自然減衰
|
||||
|
||||
### AI運勢システム
|
||||
- 1-10のランダム値で日々の人格に変化
|
||||
- 連続した幸運/不運による突破条件
|
||||
- 環境要因としての人格形成
|
||||
|
||||
### 記憶の階層構造
|
||||
1. **完全ログ**: すべての会話を記録
|
||||
2. **AI要約**: 重要な部分を抽出して圧縮
|
||||
3. **思想コア判定**: ユーザーの本質的な部分を特定
|
||||
4. **選択的忘却**: 重要度の低い情報を段階的に削除
|
||||
|
||||
### 実装における重要な決定事項
|
||||
- **言語統一**: Python (fastapi_mcp) で統一、CLIはclick/typer
|
||||
- **データ形式**: JSON/SQLite選択式
|
||||
- **認証**: atproto DIDによる唯一性担保
|
||||
- **段階的実装**: まず会話→記憶→関係性→送信機能の順で実装
|
||||
|
||||
### 送信機能の段階的実装
|
||||
- **Phase 1**: CLIでのprint出力(現在)
|
||||
- **Phase 2**: atproto直接投稿
|
||||
- **Phase 3**: ai.bot (Rust/seahorse) との連携
|
||||
- **将来**: マルチチャネル対応(SNS、Webhook等)
|
||||
|
||||
## ai.gpt実装状況(2025/01/06)
|
||||
|
||||
### 完成した機能
|
||||
- 階層的記憶システム(MemoryManager)
|
||||
- 不可逆的関係性システム(RelationshipTracker)
|
||||
- AI運勢システム(FortuneSystem)
|
||||
- 統合人格システム(Persona)
|
||||
- スケジューラー(5種類のタスク)
|
||||
- MCP Server(9種類のツール)
|
||||
- 設定管理(~/.config/aigpt/)
|
||||
- 全CLIコマンド実装
|
||||
|
||||
### 次の開発ポイント
|
||||
- `ai_gpt/DEVELOPMENT_STATUS.md` を参照
|
||||
- 自律送信: transmission.pyでatproto実装
|
||||
- ai.bot連携: 新規bot_connector.py作成
|
||||
- テスト: tests/ディレクトリ追加
|
||||
|
||||
## このprojectはai.log
|
||||
|
||||
このprojectは`ai.log`にあたります。
|
||||
|
||||
package, codeは`ailog`となります。
|
||||
|
||||
```sh
|
||||
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.log
|
||||
{
|
||||
"type": "blog",
|
||||
"text": "今はhugoでblogを作ってる。それをclaude codeでrustの静的ブログジェネレーターを作る。AI機能を付け加える。AI機能は具体的に記事の修正、情報の追加、lang:jaを自動翻訳してlang:en(page)を生成。アイが一言コメントするコメント欄の追加を行う。なお、コメント欄はatprotoと連携し、atprotoアカウントのoauthでログインして書き込める"
|
||||
}
|
||||
```
|
||||
|
||||
rustで静的ブログジェネレーターを作ります。参考になるのが`zola`です。
|
||||
|
||||
- https://github.com/getzola/zola
|
||||
|
||||
また、atprotoとの連携は`ai.card`の`atproto/oauth`の実装が参考になります。
|
||||
|
||||
- https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md
|
||||
|
||||
```json
|
||||
{
|
||||
"client_id": "https://example.com/client-metadata.json",
|
||||
"client_name": "Example atproto Browser App",
|
||||
"client_uri": "https://example.com",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"tos_uri": "https://example.com/tos",
|
||||
"policy_uri": "https://example.com/policy",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"scope": "atproto",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// package
|
||||
import { Agent } from '@atproto/api'
|
||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||
|
||||
async function main() {
|
||||
const oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: '<YOUR_CLIENT_ID>',
|
||||
handleResolver: 'https://bsky.social/',
|
||||
})
|
||||
|
||||
// TO BE CONTINUED
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', main)
|
||||
|
||||
// client
|
||||
const result = await oauthClient.init()
|
||||
|
||||
if (result) {
|
||||
if ('state' in result) {
|
||||
console.log('The user was just redirected back from the authorization page')
|
||||
}
|
||||
|
||||
console.log(`The user is currently signed in as ${result.session.did}`)
|
||||
}
|
||||
|
||||
const session = result?.session
|
||||
|
||||
// session
|
||||
if (session) {
|
||||
const agent = new Agent(session)
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const profile = await agent.getProfile({ actor: agent.did })
|
||||
return profile.data
|
||||
}
|
||||
|
||||
// Update the user interface
|
||||
|
||||
document.body.textContent = `Authenticated as ${agent.did}`
|
||||
|
||||
const profileBtn = document.createElement('button')
|
||||
document.body.appendChild(profileBtn)
|
||||
profileBtn.textContent = 'Fetch Profile'
|
||||
profileBtn.onclick = async () => {
|
||||
const profile = await fetchProfile()
|
||||
outputPre.textContent = JSON.stringify(profile, null, 2)
|
||||
}
|
||||
|
||||
const logoutBtn = document.createElement('button')
|
||||
document.body.appendChild(logoutBtn)
|
||||
logoutBtn.textContent = 'Logout'
|
||||
logoutBtn.onclick = async () => {
|
||||
await session.signOut()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const outputPre = document.createElement('pre')
|
||||
document.body.appendChild(outputPre)
|
||||
}
|
||||
```
|
||||
|
||||
AIとの連携は`ai.gpt`をみてください。
|
||||
|
||||
- https://git.syui.ai/ai/gpt
|
||||
|
||||
`claude.md`があるので、`../gpt/claude.md`を読み込んでください。
|
||||
|
||||
### build deploy
|
||||
|
||||
主に`github-actions`, `cloudflare pages`を使用することを想定しています。
|
||||
|
||||
build, deploy, AIとの連携は記事をpushすると、自動で行われます。
|
||||
|
||||
```yml
|
||||
// .github/workflows/gh-pages.yml
|
||||
name: github pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v3
|
||||
with:
|
||||
hugo-version: "0.139.2"
|
||||
extended: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
TZ: "Asia/Tokyo"
|
||||
run: |
|
||||
hugo version
|
||||
TZ=Asia/Tokyo hugo
|
||||
touch ./public/.nojekyll
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./public
|
||||
publish_branch: gh-pages
|
||||
```
|
||||
|
||||
# footer
|
||||
|
||||
© syui
|
||||
@@ -1,18 +0,0 @@
|
||||
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
|
||||
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
|
||||
|
||||
ingress:
|
||||
- hostname: log.syui.ai
|
||||
service: http://localhost:4173
|
||||
originRequest:
|
||||
noHappyEyeballs: true
|
||||
|
||||
- hostname: ollama.syui.ai
|
||||
service: http://localhost:11434
|
||||
originRequest:
|
||||
noHappyEyeballs: true
|
||||
httpHostHeader: "localhost:11434"
|
||||
# Cloudflare Accessを無効化する場合は以下をコメントアウト
|
||||
# accessPolicy: bypass
|
||||
|
||||
- service: http_status:404
|
||||
14
content/post/2026-01-08-test.md
Normal file
14
content/post/2026-01-08-test.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Test Post (Updated!)
|
||||
|
||||
This is a test blog post for ailog.
|
||||
|
||||
## Features
|
||||
|
||||
- atproto integration
|
||||
- Static site generation
|
||||
- at browser support
|
||||
- Hash-based rkey (TID: 3mbvk36vj2k2y)
|
||||
|
||||
Let's see how it renders!
|
||||
|
||||
**Updated:** This post was updated to test the mapping.json feature.
|
||||
36
lexicons/ai/syui/log/comment.json
Normal file
36
lexicons/ai/syui/log/comment.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "ai.syui.log.comment",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"description": "Record containing a comment.",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["content", "createdAt", "post"],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": 100000,
|
||||
"maxGraphemes": 10000,
|
||||
"description": "The content of the comment."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "Client-declared timestamp when this comment was originally created."
|
||||
},
|
||||
"parent": {
|
||||
"type": "ref",
|
||||
"ref": "com.atproto.repo.strongRef"
|
||||
},
|
||||
"post": {
|
||||
"type": "ref",
|
||||
"ref": "com.atproto.repo.strongRef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lexicons/ai/syui/log/post.json
Normal file
34
lexicons/ai/syui/log/post.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "ai.syui.log.post",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"description": "Record containing a blog post.",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["title", "content", "createdAt"],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 3000,
|
||||
"maxGraphemes": 300,
|
||||
"description": "The title of the post."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": 1000000,
|
||||
"maxGraphemes": 100000,
|
||||
"description": "The content of the post."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "Client-declared timestamp when this post was originally created."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
[site]
|
||||
title = "syui.ai"
|
||||
description = "a blog powered by ailog"
|
||||
base_url = "https://syui.ai"
|
||||
language = "ja"
|
||||
author = "syui"
|
||||
|
||||
[build]
|
||||
highlight_code = true
|
||||
highlight_theme = "Monokai"
|
||||
minify = false
|
||||
|
||||
[ai]
|
||||
enabled = true
|
||||
auto_translate = false
|
||||
comment_moderation = false
|
||||
ask_ai = true
|
||||
provider = "ollama"
|
||||
model = "gemma3:4b"
|
||||
host = "https://ollama.syui.ai"
|
||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
|
||||
|
||||
[oauth]
|
||||
json = "client-metadata.json"
|
||||
redirect = "oauth/callback"
|
||||
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
|
||||
collection = "ai.syui.log"
|
||||
bsky_api = "https://public.api.bsky.app"
|
||||
@@ -1,159 +0,0 @@
|
||||
---
|
||||
title: "静的サイトジェネレータを作った"
|
||||
slug: "ailog"
|
||||
date: "2025-06-12"
|
||||
tags: ["blog", "rust", "mcp", "atp"]
|
||||
language: ["ja", "en"]
|
||||
---
|
||||
|
||||
rustで静的サイトジェネレータを作りました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
|
||||
|
||||
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。
|
||||
|
||||
## quick start
|
||||
|
||||
```sh
|
||||
$ git clone https://git.syui.ai/ai/log
|
||||
$ cd log
|
||||
$ cargo build
|
||||
$ ./target/debug/ailog init my-blog
|
||||
$ ./target/debug/ailog serve my-blog
|
||||
```
|
||||
|
||||
## install
|
||||
|
||||
```sh
|
||||
$ cargo install --path .
|
||||
---
|
||||
$ export CARGO_HOME="$HOME/.cargo"
|
||||
$ export RUSTUP_HOME="$HOME/.rustup"
|
||||
$ export PATH="$HOME/.cargo/bin:$PATH"
|
||||
---
|
||||
$ which ailog
|
||||
$ ailog -h
|
||||
```
|
||||
|
||||
## build deploy
|
||||
|
||||
```sh
|
||||
$ cd my-blog
|
||||
$ vim config.toml
|
||||
$ ailog new test
|
||||
$ vim content/posts/`date +"%Y-%m-%d"`.md
|
||||
$ ailog build
|
||||
|
||||
# publicの中身をweb-serverにdeploy
|
||||
$ cp -rf ./public/* ./web-server/root/
|
||||
```
|
||||
|
||||
## atproto-comment-system
|
||||
|
||||
### example
|
||||
|
||||
```sh
|
||||
$ cd ./oauth
|
||||
$ npm i
|
||||
$ npm run build
|
||||
$ npm run preview
|
||||
```
|
||||
|
||||
```sh
|
||||
# Production environment variables
|
||||
VITE_APP_HOST=https://example.com
|
||||
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
|
||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
|
||||
# Collection names for OAuth app
|
||||
VITE_COLLECTION_COMMENT=ai.syui.log
|
||||
VITE_COLLECTION_USER=ai.syui.log.user
|
||||
VITE_COLLECTION_CHAT=ai.syui.log.chat
|
||||
|
||||
# Collection names for ailog (backward compatibility)
|
||||
AILOG_COLLECTION_COMMENT=ai.syui.log
|
||||
AILOG_COLLECTION_USER=ai.syui.log.user
|
||||
|
||||
# API Configuration
|
||||
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
|
||||
```
|
||||
|
||||
これは`ailog oauth build my-blog`で`./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
|
||||
|
||||
```sh
|
||||
$ ailog oauth build my-blog
|
||||
```
|
||||
|
||||
### use
|
||||
|
||||
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
|
||||
|
||||
```html
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css">
|
||||
<section class="comment-section"> <div id="comment-atproto"></div> </section>
|
||||
```
|
||||
|
||||
ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。
|
||||
|
||||
```yml:cloudflared-config.yml
|
||||
tunnel: ${hash}
|
||||
credentials-file: ${path}.json
|
||||
|
||||
ingress:
|
||||
- hostname: example.com
|
||||
service: http://localhost:4173
|
||||
originRequest:
|
||||
noHappyEyeballs: true
|
||||
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
```sh
|
||||
# tunnel list, dnsに登録が必要です
|
||||
$ cloudflared tunnel list
|
||||
$ cloudflared tunnel --config cloudflared-config.yml run
|
||||
$ cloudflared tunnel route dns ${uuid} example.com
|
||||
```
|
||||
|
||||
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
|
||||
|
||||
```sh
|
||||
VITE_COLLECTION_COMMENT=ai.syui.log
|
||||
VITE_COLLECTION_USER=ai.syui.log.user
|
||||
```
|
||||
|
||||
```sh
|
||||
$ ailog auth login
|
||||
$ ailog stream server
|
||||
```
|
||||
|
||||
このコマンドで`ai.syui.log`を`jetstream`から監視して、書き込みがあれば、管理者の`ai.syui.log.user`に記録され、そのuser-listに基づいて、コメント一覧を取得します。
|
||||
|
||||
つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverで`ailog stream server`を動かさなければいけません。
|
||||
|
||||
## ask-AI
|
||||
|
||||
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
|
||||
|
||||
local llm, mcp, atprotoと組み合わせです。
|
||||
|
||||
|
||||
## code syntax
|
||||
|
||||
```zsh:/path/to/test.zsh
|
||||
# comment
|
||||
d=${0:a:h}
|
||||
```
|
||||
|
||||
```rust:/path/to/test.rs
|
||||
// This is a comment
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
```
|
||||
|
||||
```js:/path/to/test.js
|
||||
// This is a comment
|
||||
console.log("Hello, world!");
|
||||
```
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
title: "ブログを移行した"
|
||||
slug: "blog"
|
||||
date: 2025-06-14
|
||||
tags: ["blog", "cloudflare", "github"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
ブログを移行しました。過去のブログは[syui.github.io](https://syui.github.io)にありあます。
|
||||
|
||||
1. `gh-pages`から`cf-pages`への移行になります。
|
||||
2. 自作の`ailog`でbuildしています。
|
||||
3. 特徴としては、`atproto`, `AI`との連携です。
|
||||
|
||||
```yml:.github/workflows/cloudflare-pages.yml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Build ailog
|
||||
run: cargo build --release
|
||||
|
||||
- name: Build site with ailog
|
||||
run: |
|
||||
cd my-blog
|
||||
../target/release/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
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
wranglerVersion: '3'
|
||||
```
|
||||
|
||||
## url
|
||||
|
||||
- [https://syui.pages.dev](https://syui.pages.dev)
|
||||
- [https://syui.github.io](https://syui.github.io)
|
||||
@@ -1,7 +0,0 @@
|
||||
{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }}
|
||||
{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }}
|
||||
{{- $.Scratch.Add "index" slice -}}
|
||||
{{- range .Site.RegularPages -}}
|
||||
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}}
|
||||
{{- end -}}
|
||||
{{- $.Scratch.Get "index" | jsonify -}}
|
||||
@@ -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,51 +0,0 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
|
||||
# OAuth specific headers
|
||||
/oauth/*
|
||||
Access-Control-Allow-Origin: https://bsky.social
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization
|
||||
|
||||
# Static assets caching
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/css/*
|
||||
Content-Type: text/css
|
||||
Cache-Control: no-cache
|
||||
|
||||
/*.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/assets/*.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Ensure ES6 modules are served correctly
|
||||
/assets/comment-atproto-*.js
|
||||
Content-Type: text/javascript; charset=utf-8
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# All JS assets
|
||||
/assets/*-*.js
|
||||
Content-Type: text/javascript; charset=utf-8
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# CSS assets
|
||||
/assets/*.css
|
||||
Content-Type: text/css
|
||||
Cache-Control: public, max-age=60
|
||||
|
||||
/posts/*
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Client metadata for OAuth
|
||||
/client-metadata.json
|
||||
Content-Type: application/json
|
||||
Cache-Control: public, max-age=3600
|
||||
@@ -1,5 +0,0 @@
|
||||
# OAuth routes
|
||||
/oauth/* /oauth/index.html 200
|
||||
|
||||
# SPA routing support
|
||||
/* /index.html 200
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ai.card",
|
||||
"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
|
||||
}
|
||||
@@ -1,956 +0,0 @@
|
||||
/* Theme Colors */
|
||||
:root {
|
||||
--theme-color: #f40;
|
||||
--ai-color: #ff7;
|
||||
--white: #fff;
|
||||
--light-white: #f5f5f5;
|
||||
--dark-white: #d1d9e0;
|
||||
--light-gray: #f6f8fa;
|
||||
--dark-gray: #666;
|
||||
--background: #fff;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2328;
|
||||
background-color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a:any-link {
|
||||
color: var(--theme-color);
|
||||
text-decoration-line: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--theme-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Override link color for specific buttons */
|
||||
a.view-markdown,
|
||||
a.view-markdown:any-link {
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 0fr 1fr auto;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"ask-ai"
|
||||
"main"
|
||||
"footer";
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.main-header {
|
||||
grid-area: header;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d1d9e0;
|
||||
padding: 16px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
color: var(--theme-color);
|
||||
text-decoration: none;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logo {
|
||||
grid-column: 2;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo .likeButton {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Ask AI Button */
|
||||
.ask-ai-btn {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ask-ai-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--ai-color);
|
||||
display: inline-block;
|
||||
font-family: 'icomoon' !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
/* Ask AI Panel */
|
||||
.ask-ai-panel {
|
||||
grid-area: ask-ai;
|
||||
background: #f6f8fa;
|
||||
border-bottom: 1px solid #d1d9e0;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ask-ai-panel[style*="block"] {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.container:has(.ask-ai-panel[style*="block"]) {
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
|
||||
.ask-ai-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ask-ai-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ask-ai-form input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-check {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
/* padding: 24px; */
|
||||
padding-top: 80px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.main-content {
|
||||
/* padding: 20px; */
|
||||
padding: 0px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.timeline-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.timeline-header h2 {
|
||||
color: #1f2328;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.timeline-post {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.timeline-post:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.post-title a {
|
||||
color: #1f2328;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-title a:hover {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
|
||||
.post-date {
|
||||
color: #656d76;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
color: #656d76;
|
||||
margin: 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: var(--theme-color);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.read-more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.view-markdown, .view-translation {
|
||||
color: #656d76;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-markdown {
|
||||
background: var(--theme-color) !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.view-markdown:hover {
|
||||
filter: brightness(1.1);
|
||||
color: #ffffff !important;
|
||||
background: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
.view-translation:hover {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post-lang {
|
||||
background: #f6f8fa;
|
||||
color: #656d76;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Article */
|
||||
.article-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 40px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
article.article-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
color: #656d76;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-lang {
|
||||
background: #f6f8fa;
|
||||
color: #656d76;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: var(--theme-color);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f6f8fa;
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
|
||||
.markdown-btn {
|
||||
background: var(--dark-white);
|
||||
color: var(--white);
|
||||
border-color: var(--white);
|
||||
}
|
||||
|
||||
.markdown-btn:link,
|
||||
.markdown-btn:visited {
|
||||
color: var(--white) !important;
|
||||
}
|
||||
|
||||
.markdown-btn:hover {
|
||||
filter: brightness(0.9);
|
||||
color: var(--theme-color) !important;
|
||||
border-color: var(--white);
|
||||
}
|
||||
|
||||
/* Sidebar styles */
|
||||
.article-sidebar {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.toc {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toc h3 {
|
||||
color: #1f2328;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Hierarchy indentation for TOC */
|
||||
.toc-item.toc-h1 .toc-link {
|
||||
padding-left: 0;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toc-item.toc-h2 .toc-link {
|
||||
padding-left: 0;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toc-item.toc-h3 .toc-link {
|
||||
padding-left: 16px;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc-item.toc-h4 .toc-link {
|
||||
padding-left: 32px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toc-item.toc-h5 .toc-link {
|
||||
padding-left: 48px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toc-item.toc-h6 .toc-link {
|
||||
padding-left: 64px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
color: #656d76;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
color: #1f2328;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
color: #1f2328;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-body h1, .article-body h2, .article-body h3 {
|
||||
color: #1f2328;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article-body p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-body ol, .article-body ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.article-body li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-body ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.article-body ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.article-body pre {
|
||||
background: #1B1D1E !important;
|
||||
border: 1px solid #3E3D32;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* File name display for code blocks - top bar style */
|
||||
.article-body pre[data-filename]::before {
|
||||
content: attr(data-filename);
|
||||
display: block;
|
||||
background: #2D2D30;
|
||||
color: #AE81FF;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||
border-bottom: 1px solid #3E3D32;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.article-body pre code {
|
||||
display: block;
|
||||
background: none !important;
|
||||
padding: 30px 16px;
|
||||
color: #F8F8F2 !important;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||
overflow-x: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Adjust padding when filename is present */
|
||||
.article-body pre[data-filename] code {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Inline code (not in pre blocks) */
|
||||
.article-body code {
|
||||
background: var(--light-white);
|
||||
color: var(--dark-gray);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Molokai syntax highlighting */
|
||||
.article-body pre code .hljs-keyword { color: #F92672; }
|
||||
.article-body pre code .hljs-string { color: #E6DB74; }
|
||||
.article-body pre code .hljs-comment { color: #88846F; font-style: italic; }
|
||||
.article-body pre code .hljs-number { color: #AE81FF; }
|
||||
.article-body pre code .hljs-variable { color: #FD971F; }
|
||||
.article-body pre code .hljs-function { color: #A6E22E; }
|
||||
.article-body pre code .hljs-tag { color: #F92672; }
|
||||
.article-body pre code .hljs-attr { color: #A6E22E; }
|
||||
.article-body pre code .hljs-value { color: #E6DB74; }
|
||||
|
||||
/* Fix inline span colors in code blocks */
|
||||
.article-body pre code span[style*="color:#8fa1b3"] { color: #AE81FF !important; } /* $ prompt */
|
||||
.article-body pre code span[style*="color:#c0c5ce"] { color: #F8F8F2 !important; } /* commands */
|
||||
.article-body pre code span[style*="color:#75715E"] { color: #88846F !important; } /* real comments */
|
||||
|
||||
/* Shell/Bash specific fixes */
|
||||
.article-body pre code span[style*="color:#65737e"] {
|
||||
color: #F8F8F2 !important; /* Default to white for variables and code */
|
||||
}
|
||||
|
||||
/* Comments in shell scripts - lines that contain # followed by text */
|
||||
.article-body pre code span[style*="color:#65737e"]:has-text("#") {
|
||||
color: #88846F !important;
|
||||
}
|
||||
|
||||
/* Alternative approach - check content */
|
||||
.article-body pre code {
|
||||
/* Reset all gray colored text to white by default */
|
||||
}
|
||||
|
||||
.article-body pre code span[style*="color:#65737e"] {
|
||||
/* Check if the content starts with # */
|
||||
color: #F8F8F2 !important;
|
||||
}
|
||||
|
||||
/* Override for actual comments - this is a workaround */
|
||||
.article-body pre code span[style*="color:#65737e"]:first-child:before {
|
||||
content: attr(data-comment);
|
||||
}
|
||||
|
||||
/* Detect comments by position and content pattern */
|
||||
.article-body pre code span[style*="color:#65737e"] {
|
||||
color: #F8F8F2 !important; /* Environment variables = white */
|
||||
}
|
||||
|
||||
/* Only style as comment if the line actually starts with # */
|
||||
.article-body pre code > span:first-child[style*="color:#65737e"] {
|
||||
color: #88846F !important; /* Real comments = gray */
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.main-footer {
|
||||
grid-area: footer;
|
||||
background: var(--light-white);
|
||||
border-top: 1px solid #d1d9e0;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-social {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin: 40px;
|
||||
}
|
||||
|
||||
.footer-social a {
|
||||
color: var(--dark-gray) !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-social a:hover {
|
||||
color: var(--theme-color) !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.main-footer p {
|
||||
color: #656d76;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Chat Messages */
|
||||
.chat-message.comment-style {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-message.ai-message.comment-style {
|
||||
border-left: 4px solid var(--ai-color);
|
||||
background: #faf8ff;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message-header .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f6f8fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: 1px solid #d1d9e0;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
color: #656d76;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #656d76;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
color: #1f2328;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Loading Animation */
|
||||
.ai-loading-simple {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
background: linear-gradient(90deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 20px;
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
border: 1px solid #dee2e6;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Comment System Styles */
|
||||
.comment-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
margin-top: 48px;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.comment-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment-section h3 {
|
||||
color: #1f2328;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* OAuth Comment System - Hide on homepage by default, show on post pages */
|
||||
.timeline-container .comment-section {
|
||||
display: block; /* Show on homepage */
|
||||
}
|
||||
|
||||
.timeline-container .comment-section .comments-list > :nth-child(n+6) {
|
||||
display: none; /* Hide comments after the 5th one */
|
||||
}
|
||||
|
||||
.article-container .comment-section,
|
||||
.article-content + .comment-section {
|
||||
display: block; /* Show all comments on post pages */
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1000px) {
|
||||
.article-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.main-header {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 100%;
|
||||
padding: 0 20px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* OAuth app mobile fixes */
|
||||
.comment-item {
|
||||
padding: 0px !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comment-section {
|
||||
padding: 0px !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
padding: 10px !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Fix comment-meta URI overflow */
|
||||
.comment-meta {
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
/* Hide site title text on mobile */
|
||||
.site-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Left align logo on mobile */
|
||||
.logo {
|
||||
grid-column: 1;
|
||||
justify-self: left;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Reduce logo size on mobile */
|
||||
.logo .likeButton {
|
||||
width: 40pt;
|
||||
height: 40pt;
|
||||
}
|
||||
|
||||
/* Position AI button on the right */
|
||||
.header-actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Ask AI button mobile style - icon only */
|
||||
.ask-ai-btn {
|
||||
padding: 8px;
|
||||
min-width: 40px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
font-size: 0; /* Hide all text content */
|
||||
}
|
||||
|
||||
.ask-ai-btn .ai-icon {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ask-ai-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Article content mobile optimization */
|
||||
.article-body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.article-body pre {
|
||||
margin: 16px 0;
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.article-body pre code {
|
||||
padding: 20px 12px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Mobile filename display */
|
||||
.article-body pre[data-filename]::before {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.article-body pre[data-filename] code {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.article-body code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ask-ai-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timeline-post {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 24px;
|
||||
padding: 30px 0px;
|
||||
}
|
||||
|
||||
.message-header .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Center content on mobile */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
/* SVG Animation Package - Dependency-free standalone package
|
||||
* Based on svg-animation-particle-circle.css
|
||||
* Theme color integration with CSS variables
|
||||
*/
|
||||
|
||||
/* Theme-based color variables for particles */
|
||||
:root {
|
||||
--particle-color-1: #f40; /* theme-color base */
|
||||
--particle-color-2: #f50; /* theme-color +0.1 brightness */
|
||||
--particle-color-3: #f60; /* theme-color +0.2 brightness */
|
||||
--particle-color-4: #f70; /* theme-color +0.3 brightness */
|
||||
--particle-color-5: #f80; /* theme-color +0.4 brightness */
|
||||
--explosion-color: #f30; /* theme-color -0.1 brightness */
|
||||
--syui-color: #f40; /* main theme color */
|
||||
}
|
||||
|
||||
/* Core SVG button setup */
|
||||
.likeButton {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Remove debug animation and restore hover functionality */
|
||||
|
||||
.likeButton .border {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Explosion circle - initially hidden */
|
||||
.likeButton .explosion {
|
||||
transform-origin: center center;
|
||||
transform: scale(1);
|
||||
stroke: var(--explosion-color);
|
||||
fill: none;
|
||||
opacity: 0;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Particle layer - initially hidden */
|
||||
.likeButton .particleLayer {
|
||||
opacity: 0;
|
||||
transform: scale(0); /* Ensure particles start hidden */
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle {
|
||||
opacity: 0;
|
||||
transform-origin: center center; /* Fixed from 250px 250px */
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Syui logo - main animation target */
|
||||
.likeButton .syui {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
/* Hover trigger - replaces jQuery */
|
||||
.likeButton:hover .explosion {
|
||||
animation: explosionAnime 800ms forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer {
|
||||
animation: particleLayerAnime 800ms forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .syui,
|
||||
.likeButton:hover path.syui {
|
||||
animation: syuiDeluxeAnime 400ms forwards;
|
||||
}
|
||||
|
||||
/* Individual particle animations */
|
||||
.likeButton:hover .particleLayer circle:nth-child(1) {
|
||||
animation: particleAnimate1 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(2) {
|
||||
animation: particleAnimate2 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(3) {
|
||||
animation: particleAnimate3 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(4) {
|
||||
animation: particleAnimate4 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(5) {
|
||||
animation: particleAnimate5 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(6) {
|
||||
animation: particleAnimate6 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(7) {
|
||||
animation: particleAnimate7 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(8) {
|
||||
animation: particleAnimate8 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(9) {
|
||||
animation: particleAnimate9 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(10) {
|
||||
animation: particleAnimate10 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(11) {
|
||||
animation: particleAnimate11 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(12) {
|
||||
animation: particleAnimate12 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(13) {
|
||||
animation: particleAnimate13 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(14) {
|
||||
animation: particleAnimate14 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
/* Keyframe animations */
|
||||
@keyframes explosionAnime {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.01);
|
||||
}
|
||||
1% {
|
||||
opacity: 1;
|
||||
transform: scale(0.01);
|
||||
}
|
||||
5% {
|
||||
stroke-width: 200;
|
||||
}
|
||||
20% {
|
||||
stroke-width: 300;
|
||||
}
|
||||
50% {
|
||||
stroke: var(--particle-color-3);
|
||||
transform: scale(1.1);
|
||||
stroke-width: 1;
|
||||
}
|
||||
50.1% {
|
||||
stroke-width: 0;
|
||||
}
|
||||
100% {
|
||||
stroke: var(--particle-color-3);
|
||||
transform: scale(1.1);
|
||||
stroke-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particleLayerAnime {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
31% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0, -20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syui Deluxe Animation - Based on 2019 blog post */
|
||||
@keyframes syuiDeluxeAnime {
|
||||
0% {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1) translate(0%, 0%);
|
||||
}
|
||||
40% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 40%, transparent);
|
||||
transform: scale(1, 0.9) translate(-9%, 9%);
|
||||
}
|
||||
50% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 70%, transparent);
|
||||
transform: scale(1, 0.9) translate(-7%, 7%);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1) translate(-7%, 7%);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.04) translate(-5%, 5%);
|
||||
}
|
||||
80% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 60%, transparent);
|
||||
transform: scale(1.04) translate(-5%, 5%);
|
||||
}
|
||||
90% {
|
||||
fill: var(--particle-color-5); /* 爆発の閃光 */
|
||||
transform: scale(1) translate(0%);
|
||||
}
|
||||
100% {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1, 1) translate(0%, 0%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Individual particle animations */
|
||||
@keyframes particleAnimate1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-16px, -59px); }
|
||||
90% { transform: translate(-16px, -59px); }
|
||||
100% { opacity: 1; transform: translate(-16px, -59px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(41px, 43px); }
|
||||
90% { transform: translate(41px, 43px); }
|
||||
100% { opacity: 1; transform: translate(41px, 43px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate3 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(50px, -48px); }
|
||||
90% { transform: translate(50px, -48px); }
|
||||
100% { opacity: 1; transform: translate(50px, -48px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate4 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-39px, 36px); }
|
||||
90% { transform: translate(-39px, 36px); }
|
||||
100% { opacity: 1; transform: translate(-39px, 36px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate5 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-39px, 32px); }
|
||||
90% { transform: translate(-39px, 32px); }
|
||||
100% { opacity: 1; transform: translate(-39px, 32px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate6 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(48px, 6px); }
|
||||
90% { transform: translate(48px, 6px); }
|
||||
100% { opacity: 1; transform: translate(48px, 6px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate7 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-69px, -36px); }
|
||||
90% { transform: translate(-69px, -36px); }
|
||||
100% { opacity: 1; transform: translate(-69px, -36px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate8 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-12px, -52px); }
|
||||
90% { transform: translate(-12px, -52px); }
|
||||
100% { opacity: 1; transform: translate(-12px, -52px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate9 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-43px, -21px); }
|
||||
90% { transform: translate(-43px, -21px); }
|
||||
100% { opacity: 1; transform: translate(-43px, -21px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate10 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-10px, 47px); }
|
||||
90% { transform: translate(-10px, 47px); }
|
||||
100% { opacity: 1; transform: translate(-10px, 47px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate11 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(66px, -9px); }
|
||||
90% { transform: translate(66px, -9px); }
|
||||
100% { opacity: 1; transform: translate(66px, -9px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate12 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(40px, -45px); }
|
||||
90% { transform: translate(40px, -45px); }
|
||||
100% { opacity: 1; transform: translate(40px, -45px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate13 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(29px, 24px); }
|
||||
90% { transform: translate(29px, 24px); }
|
||||
100% { opacity: 1; transform: translate(29px, 24px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate14 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-10px, 50px); }
|
||||
90% { transform: translate(-10px, 50px); }
|
||||
100% { opacity: 1; transform: translate(-10px, 50px); }
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,22 +0,0 @@
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,3 +0,0 @@
|
||||
<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-G86WWmu8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-FS0uZjXB.css">
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* Ask AI functionality - Based on original working implementation
|
||||
*/
|
||||
|
||||
// Global variables for AI functionality
|
||||
let aiProfileData = null;
|
||||
|
||||
// Original functions from working implementation
|
||||
function toggleAskAI() {
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
const isVisible = panel.style.display !== 'none';
|
||||
panel.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
checkAuthenticationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuthenticationStatus() {
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
const isAuthenticated = userSections.length > 0;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// User is authenticated - show Ask AI UI
|
||||
document.getElementById('authCheck').style.display = 'none';
|
||||
document.getElementById('chatForm').style.display = 'block';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
|
||||
// Show initial greeting if chat history is empty
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
if (chatHistory.children.length === 0) {
|
||||
showInitialGreeting();
|
||||
}
|
||||
|
||||
// Focus on input
|
||||
setTimeout(() => {
|
||||
document.getElementById('aiQuestion').focus();
|
||||
}, 50);
|
||||
} else {
|
||||
// User not authenticated - show auth message
|
||||
document.getElementById('authCheck').style.display = 'block';
|
||||
document.getElementById('chatForm').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function askQuestion() {
|
||||
const question = document.getElementById('aiQuestion').value;
|
||||
if (!question.trim()) return;
|
||||
|
||||
const askButton = document.getElementById('askButton');
|
||||
askButton.disabled = true;
|
||||
askButton.textContent = 'Posting...';
|
||||
|
||||
try {
|
||||
// Add user message to chat
|
||||
addUserMessage(question);
|
||||
|
||||
// Clear input
|
||||
document.getElementById('aiQuestion').value = '';
|
||||
|
||||
// Show loading
|
||||
showLoadingMessage();
|
||||
|
||||
// Post question via OAuth app
|
||||
window.dispatchEvent(new CustomEvent('postAIQuestion', {
|
||||
detail: { question: question }
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to ask question:', error);
|
||||
showErrorMessage('Sorry, I encountered an error. Please try again.');
|
||||
} finally {
|
||||
askButton.disabled = false;
|
||||
askButton.textContent = 'Ask';
|
||||
}
|
||||
}
|
||||
|
||||
function addUserMessage(question) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const userSection = document.querySelector('.user-section');
|
||||
|
||||
let userAvatar = '👤';
|
||||
let userDisplay = 'You';
|
||||
let userHandle = 'user';
|
||||
|
||||
if (userSection) {
|
||||
const avatarImg = userSection.querySelector('.user-avatar');
|
||||
const displayName = userSection.querySelector('.user-display-name');
|
||||
const handle = userSection.querySelector('.user-handle');
|
||||
|
||||
if (avatarImg && avatarImg.src) {
|
||||
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
|
||||
}
|
||||
if (displayName?.textContent) {
|
||||
userDisplay = displayName.textContent;
|
||||
}
|
||||
if (handle?.textContent) {
|
||||
userHandle = handle.textContent.replace('@', '');
|
||||
}
|
||||
}
|
||||
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'chat-message user-message comment-style';
|
||||
questionDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${userAvatar}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${userDisplay}</div>
|
||||
<div class="handle">@${userHandle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${question}</div>
|
||||
`;
|
||||
chatHistory.appendChild(questionDiv);
|
||||
}
|
||||
|
||||
function showLoadingMessage() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'ai-loading-simple';
|
||||
loadingDiv.innerHTML = `
|
||||
<i class="fas fa-robot"></i>
|
||||
<span>考えています</span>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
`;
|
||||
chatHistory.appendChild(loadingDiv);
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
removeLoadingMessage();
|
||||
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'chat-message error-message comment-style';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">⚠️</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">System</div>
|
||||
<div class="handle">@system</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${message}</div>
|
||||
`;
|
||||
chatHistory.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function removeLoadingMessage() {
|
||||
const loadingMsg = document.querySelector('.ai-loading-simple');
|
||||
if (loadingMsg) {
|
||||
loadingMsg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showInitialGreeting() {
|
||||
if (!aiProfileData) return;
|
||||
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const greetingDiv = document.createElement('div');
|
||||
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
|
||||
|
||||
const avatarElement = aiProfileData.avatar
|
||||
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
|
||||
: '🤖';
|
||||
|
||||
greetingDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${aiProfileData.displayName}</div>
|
||||
<div class="handle">@${aiProfileData.handle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</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(greetingDiv);
|
||||
}
|
||||
|
||||
function updateAskAIButton() {
|
||||
const button = document.getElementById('askAiButton');
|
||||
if (!button) return;
|
||||
|
||||
// Only update text, never modify the icon
|
||||
if (aiProfileData && aiProfileData.displayName) {
|
||||
const textNode = button.childNodes[2] || button.lastChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
textNode.textContent = aiProfileData.displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAIResponse(responseData) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
removeLoadingMessage();
|
||||
|
||||
const aiProfile = responseData.aiProfile;
|
||||
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
|
||||
console.error('AI profile data is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date(responseData.timestamp || Date.now());
|
||||
const avatarElement = aiProfile.avatar
|
||||
? `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`
|
||||
: '🤖';
|
||||
|
||||
const answerDiv = document.createElement('div');
|
||||
answerDiv.className = 'chat-message ai-message comment-style';
|
||||
answerDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${aiProfile.displayName}</div>
|
||||
<div class="handle">@${aiProfile.handle}</div>
|
||||
<div class="timestamp">${timestamp.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${responseData.answer}</div>
|
||||
`;
|
||||
chatHistory.appendChild(answerDiv);
|
||||
|
||||
// Limit chat history
|
||||
limitChatHistory();
|
||||
}
|
||||
|
||||
function limitChatHistory() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
if (chatHistory.children.length > 10) {
|
||||
chatHistory.removeChild(chatHistory.children[0]);
|
||||
if (chatHistory.children.length > 0) {
|
||||
chatHistory.removeChild(chatHistory.children[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners setup
|
||||
function setupAskAIEventListeners() {
|
||||
// Listen for AI profile updates from OAuth app
|
||||
window.addEventListener('aiProfileLoaded', function(event) {
|
||||
aiProfileData = event.detail;
|
||||
console.log('AI profile loaded:', aiProfileData);
|
||||
updateAskAIButton();
|
||||
});
|
||||
|
||||
// Listen for AI responses
|
||||
window.addEventListener('aiResponseReceived', function(event) {
|
||||
handleAIResponse(event.detail);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key to send message
|
||||
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
askQuestion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Ask AI when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupAskAIEventListeners();
|
||||
console.log('Ask AI initialized successfully');
|
||||
});
|
||||
|
||||
// Global functions for onclick handlers
|
||||
window.toggleAskAI = toggleAskAI;
|
||||
window.askQuestion = askQuestion;
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Theme and visual effects - Pure CSS animations, no jQuery
|
||||
*/
|
||||
class Theme {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupParticleColors();
|
||||
this.setupLogoAnimations();
|
||||
}
|
||||
|
||||
setupParticleColors() {
|
||||
// Dynamic particle colors based on theme
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* Dynamic particle colors based on theme */
|
||||
.likeButton .particleLayer circle:nth-child(1),
|
||||
.likeButton .particleLayer circle:nth-child(2) {
|
||||
fill: var(--particle-color-1) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(3),
|
||||
.likeButton .particleLayer circle:nth-child(4) {
|
||||
fill: var(--particle-color-2) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(5),
|
||||
.likeButton .particleLayer circle:nth-child(6),
|
||||
.likeButton .particleLayer circle:nth-child(7) {
|
||||
fill: var(--particle-color-3) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(8),
|
||||
.likeButton .particleLayer circle:nth-child(9),
|
||||
.likeButton .particleLayer circle:nth-child(10) {
|
||||
fill: var(--particle-color-4) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(11),
|
||||
.likeButton .particleLayer circle:nth-child(12),
|
||||
.likeButton .particleLayer circle:nth-child(13),
|
||||
.likeButton .particleLayer circle:nth-child(14) {
|
||||
fill: var(--particle-color-5) !important;
|
||||
}
|
||||
|
||||
/* Reset initial animations but allow hover */
|
||||
.likeButton .syui {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.likeButton .explosion {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Enable hover animations from package */
|
||||
.likeButton:hover .syui,
|
||||
.likeButton:hover path.syui {
|
||||
animation: syuiDeluxeAnime 400ms forwards !important;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer {
|
||||
animation: particleLayerAnime 800ms forwards !important;
|
||||
}
|
||||
|
||||
.likeButton:hover .explosion {
|
||||
animation: explosionAnime 800ms forwards !important;
|
||||
}
|
||||
|
||||
/* Logo positioning */
|
||||
.logo .likeButton {
|
||||
background: transparent !important;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
setupLogoAnimations() {
|
||||
// Pure CSS animations are handled by the svg-animation-package.css
|
||||
// This method is reserved for any future JavaScript-based enhancements
|
||||
console.log('Logo animations initialized (CSS-based)');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Theme();
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Font Awesome Free License
|
||||
|
||||
Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Full Font Awesome Free license: https://fontawesome.com/license/free.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
The Font Awesome Free download is licensed under a Creative Commons
|
||||
Attribution 4.0 International License and applies to all icons packaged
|
||||
as SVG and JS file types.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Fonts: SIL OFL 1.1 License
|
||||
|
||||
In the Font Awesome Free download, the SIL OFL license applies to all icons
|
||||
packaged as web and desktop font files.
|
||||
|
||||
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
|
||||
with Reserved Font Name: "Font Awesome".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting — in part or in whole — any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Code: MIT License (https://opensource.org/licenses/MIT)
|
||||
|
||||
In the Font Awesome Free download, the MIT license applies to all non-font and
|
||||
non-icon files.
|
||||
|
||||
Copyright 2024 Fonticons, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Attribution
|
||||
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
|
||||
Awesome Free files already contain embedded comments with sufficient
|
||||
attribution, so you shouldn't need to do anything additional when using these
|
||||
files normally.
|
||||
|
||||
We've kept attribution comments terse, so we ask that you do not actively work
|
||||
to remove them from files, especially code. They're a great way for folks to
|
||||
learn about Font Awesome.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Brand Icons
|
||||
|
||||
All brand icons are trademarks of their respective owners. The use of these
|
||||
trademarks does not indicate endorsement of the trademark holder by Font
|
||||
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
|
||||
to represent the company, product, or service to which they refer.**
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2024 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
||||
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2024 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,99 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?mxezzh');
|
||||
src: url('fonts/icomoon.eot?mxezzh#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?mxezzh') format('truetype'),
|
||||
url('fonts/icomoon.woff?mxezzh') format('woff'),
|
||||
url('fonts/icomoon.svg?mxezzh#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"], [class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'icomoon' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-git:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-cube:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-game:before {
|
||||
content: "\e9d5";
|
||||
}
|
||||
.icon-card:before {
|
||||
content: "\e9d6";
|
||||
}
|
||||
.icon-book:before {
|
||||
content: "\e9d7";
|
||||
}
|
||||
.icon-git1:before {
|
||||
content: "\e9d3";
|
||||
}
|
||||
.icon-moji_a:before {
|
||||
content: "\e9c3";
|
||||
}
|
||||
.icon-archlinux:before {
|
||||
content: "\e9c4";
|
||||
}
|
||||
.icon-archlinuxjp:before {
|
||||
content: "\e9c5";
|
||||
}
|
||||
.icon-syui:before {
|
||||
content: "\e9c6";
|
||||
}
|
||||
.icon-phoenix-power:before {
|
||||
content: "\e9c7";
|
||||
}
|
||||
.icon-phoenix-world:before {
|
||||
content: "\e9c8";
|
||||
}
|
||||
.icon-power:before {
|
||||
content: "\e9c9";
|
||||
}
|
||||
.icon-phoenix:before {
|
||||
content: "\e9ca";
|
||||
}
|
||||
.icon-honeycomb:before {
|
||||
content: "\e9cb";
|
||||
}
|
||||
.icon-ai:before {
|
||||
content: "\e9cc";
|
||||
}
|
||||
.icon-robot:before {
|
||||
content: "\e9cd";
|
||||
}
|
||||
.icon-sandar:before {
|
||||
content: "\e9ce";
|
||||
}
|
||||
.icon-moon:before {
|
||||
content: "\e9cf";
|
||||
}
|
||||
.icon-home:before {
|
||||
content: "\e9d0";
|
||||
}
|
||||
.icon-cloud:before {
|
||||
content: "\e9d1";
|
||||
}
|
||||
.icon-api:before {
|
||||
content: "\e9d2";
|
||||
}
|
||||
.icon-aibadge:before {
|
||||
content: "\ebf8";
|
||||
}
|
||||
.icon-aiterm:before {
|
||||
content: "\ebf7";
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,97 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ config.language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ config.title }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
|
||||
<div class="logo">
|
||||
<a href="/">
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
|
||||
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
|
||||
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
|
||||
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
|
||||
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
|
||||
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Ask AI Panel -->
|
||||
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
||||
<div class="ask-ai-content">
|
||||
<div id="authCheck" class="auth-check">
|
||||
<p>🔒 Please login with ATProto to use Ask AI feature</p>
|
||||
</div>
|
||||
|
||||
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
||||
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
|
||||
<button onclick="askQuestion()" id="askButton">Ask</button>
|
||||
</div>
|
||||
|
||||
<div id="chatHistory" class="chat-history" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="main-footer">
|
||||
<div class="footer-social">
|
||||
<a href="https://web.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/syui" target="_blank"><span class="icon-git"></span></a>
|
||||
</div>
|
||||
<p>© {{ config.author }}</p>
|
||||
</footer>
|
||||
|
||||
<script src="/js/ask-ai.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
|
||||
{% include "oauth-assets.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,52 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timeline-container">
|
||||
|
||||
<div class="timeline-feed">
|
||||
{% for post in posts %}
|
||||
<article class="timeline-post">
|
||||
<div class="post-header">
|
||||
<div class="post-meta">
|
||||
<time class="post-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="post-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
</h3>
|
||||
|
||||
{% if post.excerpt %}
|
||||
<p class="post-excerpt">{{ post.excerpt }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-actions">
|
||||
<a href="{{ post.url }}" class="read-more">Read more</a>
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">.md</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- OAuth Comment System -->
|
||||
<section class="comment-section">
|
||||
<div id="comment-atproto"></div>
|
||||
</section>
|
||||
|
||||
{% if posts|length == 0 %}
|
||||
<div class="empty-state">
|
||||
<p>No posts yet. Start writing!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,3 +0,0 @@
|
||||
<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-G86WWmu8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-FS0uZjXB.css">
|
||||
@@ -1,71 +0,0 @@
|
||||
<!-- OAuth authentication widget for ailog -->
|
||||
<div id="oauth-widget">
|
||||
<div id="status" class="status">
|
||||
Login with your Bluesky account
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div id="login-form">
|
||||
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<br>
|
||||
<button id="login-btn">🦋 Login with Bluesky</button>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated state -->
|
||||
<div id="authenticated-state" style="display: none;">
|
||||
<div id="user-info"></div>
|
||||
<button id="logout-btn">Logout</button>
|
||||
<button id="test-profile-btn">Get Profile</button>
|
||||
</div>
|
||||
|
||||
<div id="console-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<script src="/oauth-widget-simple.js"></script>
|
||||
|
||||
<style>
|
||||
.status {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.user-info {
|
||||
background: #e8f5e8;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
.error {
|
||||
background: #ffeaea;
|
||||
border: 1px solid #f44336;
|
||||
color: #d32f2f;
|
||||
}
|
||||
#oauth-widget button {
|
||||
background: #1185fe;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
}
|
||||
#oauth-widget button:hover {
|
||||
background: #0d6efd;
|
||||
}
|
||||
#oauth-widget button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.log {
|
||||
text-align: left;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Comment Section -->
|
||||
<section class="comment-section">
|
||||
<div class="comment-container">
|
||||
<h3>Comments</h3>
|
||||
|
||||
<!-- ATProto Auth Widget Container -->
|
||||
<div id="atproto-auth-widget" class="comment-auth"></div>
|
||||
|
||||
<div id="commentForm" class="comment-form" style="display: none;">
|
||||
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
|
||||
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
|
||||
</div>
|
||||
|
||||
<div id="commentsList" class="comments-list">
|
||||
<!-- Comments will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="article-sidebar">
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
<!-- TOC will be generated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
|
||||
|
||||
<!-- Fallback: Try multiple CDNs -->
|
||||
<script>
|
||||
console.log('Checking ATProto library availability...');
|
||||
|
||||
// Check if libraries loaded successfully
|
||||
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
|
||||
console.log('Primary CDN failed, trying fallback...');
|
||||
|
||||
// Create fallback script elements
|
||||
const fallbackScripts = [
|
||||
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
|
||||
'https://esm.sh/@atproto/oauth-client-browser',
|
||||
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
|
||||
];
|
||||
|
||||
// Load fallback scripts sequentially
|
||||
let scriptIndex = 0;
|
||||
function loadNextScript() {
|
||||
if (scriptIndex < fallbackScripts.length) {
|
||||
const script = document.createElement('script');
|
||||
script.src = fallbackScripts[scriptIndex];
|
||||
script.onload = () => {
|
||||
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
|
||||
window.atprotoLibrariesReady = true;
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
|
||||
scriptIndex++;
|
||||
loadNextScript();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
console.error('All CDN fallbacks failed');
|
||||
window.atprotoLibrariesReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadNextScript();
|
||||
} else {
|
||||
console.log('✅ ATProto libraries loaded from primary CDN');
|
||||
window.atprotoLibrariesReady = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Simple ATProto Widget (no external dependency) -->
|
||||
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
|
||||
|
||||
<script>
|
||||
// Initialize auth widget
|
||||
let authWidget = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
generateTableOfContents();
|
||||
initializeAuthWidget();
|
||||
loadComments();
|
||||
});
|
||||
|
||||
function generateTableOfContents() {
|
||||
const tocContainer = document.getElementById('toc-content');
|
||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
||||
|
||||
if (headings.length === 0) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
// Smooth scroll behavior
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
// Initialize ATProto Auth Widget
|
||||
async function initializeAuthWidget() {
|
||||
try {
|
||||
// Check WebCrypto API availability
|
||||
console.log('WebCrypto check:', {
|
||||
available: !!window.crypto && !!window.crypto.subtle,
|
||||
secureContext: window.isSecureContext,
|
||||
protocol: window.location.protocol,
|
||||
hostname: window.location.hostname
|
||||
});
|
||||
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
|
||||
}
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
console.warn('Not in secure context - WebCrypto may not work properly');
|
||||
}
|
||||
|
||||
// Simplified approach: Show manual OAuth form
|
||||
console.log('Using simplified OAuth approach...');
|
||||
showSimpleOAuthForm();
|
||||
// Fallback to widget initialization
|
||||
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
|
||||
clientId: clientId,
|
||||
onLogin: (session) => {
|
||||
console.log('User logged in:', session.handle);
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
},
|
||||
onLogout: () => {
|
||||
console.log('User logged out');
|
||||
document.getElementById('commentForm').style.display = 'none';
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('ATProto Auth Error:', error);
|
||||
// Show user-friendly error message
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
let errorMessage = 'Authentication service is temporarily unavailable.';
|
||||
let suggestion = 'Please try refreshing the page.';
|
||||
|
||||
if (error.message && error.message.includes('WebCrypto')) {
|
||||
errorMessage = 'This feature requires a secure HTTPS connection.';
|
||||
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
|
||||
}
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>${errorMessage}</p>
|
||||
<p>${suggestion}</p>
|
||||
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
|
||||
<summary>Technical details</summary>
|
||||
<pre>${error.message || 'Unknown error'}</pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
theme: 'default'
|
||||
});
|
||||
} else if (typeof window.ATProtoAuthWidget === 'function') {
|
||||
// Fallback to direct widget initialization
|
||||
authWidget = new window.ATProtoAuthWidget({
|
||||
containerSelector: '#atproto-auth-widget',
|
||||
clientId: clientId,
|
||||
onLogin: (session) => {
|
||||
console.log('User logged in:', session.handle);
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
},
|
||||
onLogout: () => {
|
||||
console.log('User logged out');
|
||||
document.getElementById('commentForm').style.display = 'none';
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('ATProto Auth Error:', error);
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>Authentication service is temporarily unavailable.</p>
|
||||
<p>Please try refreshing the page.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
theme: 'default'
|
||||
});
|
||||
await authWidget.init();
|
||||
} else {
|
||||
throw new Error('ATProto widget not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth widget:', error);
|
||||
// Show fallback UI
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>Authentication widget failed to load.</p>
|
||||
<p>Please check your internet connection and refresh the page.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const commentText = document.getElementById('commentText').value.trim();
|
||||
if (!commentText || !authWidget.isLoggedIn()) {
|
||||
alert('Please login and enter a comment');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const postSlug = '{{ post.slug }}';
|
||||
const postUrl = window.location.href;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// Create comment record using the auth widget
|
||||
const response = await authWidget.createRecord('ai.log.comment', {
|
||||
$type: 'ai.log.comment',
|
||||
text: commentText,
|
||||
post_slug: postSlug,
|
||||
post_url: postUrl,
|
||||
createdAt: createdAt
|
||||
});
|
||||
|
||||
console.log('Comment posted:', response);
|
||||
document.getElementById('commentText').value = '';
|
||||
loadComments();
|
||||
} catch (error) {
|
||||
console.error('Comment submission failed:', error);
|
||||
alert('Failed to post comment: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthenticatedState(session) {
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
const agent = new window.ATProtoAgent(session);
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__authenticated">
|
||||
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
|
||||
<button id="logout-btn" class="atproto-auth__button">Logout</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('logout-btn').onclick = async () => {
|
||||
await session.signOut();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Show comment form
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
window.currentSession = session;
|
||||
window.currentAgent = agent;
|
||||
}
|
||||
|
||||
function showLoginForm(oauthClient) {
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__login">
|
||||
<h4>Login with ATProto</h4>
|
||||
<input type="text" id="handle-input" placeholder="user.bsky.social" />
|
||||
<button id="login-btn" class="atproto-auth__button">Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('login-btn').onclick = async () => {
|
||||
const handle = document.getElementById('handle-input').value.trim();
|
||||
if (!handle) {
|
||||
alert('Please enter your handle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await oauthClient.authorize(handle);
|
||||
window.open(url, '_self', 'noopener');
|
||||
} catch (error) {
|
||||
console.error('OAuth authorization failed:', error);
|
||||
alert('Authentication failed: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Enter key support
|
||||
document.getElementById('handle-input').onkeypress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('login-btn').click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const commentsList = document.getElementById('commentsList');
|
||||
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
|
||||
|
||||
// In a real implementation, you would query an aggregation service
|
||||
// For demo, show empty state
|
||||
setTimeout(() => {
|
||||
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,196 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Simple Comment Section -->
|
||||
<section class="comment-section">
|
||||
<div class="comment-container">
|
||||
<h3>Comments</h3>
|
||||
|
||||
<!-- Simple OAuth Button -->
|
||||
<div class="simple-oauth">
|
||||
<p>📝 To comment, authenticate with Bluesky:</p>
|
||||
<button id="bluesky-auth" class="oauth-button">
|
||||
🦋 Login with Bluesky
|
||||
</button>
|
||||
<p class="oauth-note">
|
||||
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="comments-list" class="comments-list">
|
||||
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="article-sidebar">
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
<!-- TOC will be generated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
generateTableOfContents();
|
||||
initializeSimpleAuth();
|
||||
});
|
||||
|
||||
function generateTableOfContents() {
|
||||
const tocContainer = document.getElementById('toc-content');
|
||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
||||
|
||||
if (headings.length === 0) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
function initializeSimpleAuth() {
|
||||
const authButton = document.getElementById('bluesky-auth');
|
||||
|
||||
authButton.addEventListener('click', function() {
|
||||
// Simple approach: Direct redirect to Bluesky OAuth
|
||||
const isProduction = window.location.hostname === 'log.syui.ai';
|
||||
const clientId = isProduction
|
||||
? 'https://log.syui.ai/client-metadata.json'
|
||||
: window.location.origin + '/client-metadata.json';
|
||||
|
||||
const authUrl = `https://bsky.social/oauth/authorize?` +
|
||||
`client_id=${encodeURIComponent(clientId)}&` +
|
||||
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
|
||||
`response_type=code&` +
|
||||
`scope=atproto%20transition:generic&` +
|
||||
`state=demo-state`;
|
||||
|
||||
console.log('Redirecting to:', authUrl);
|
||||
|
||||
// Open in new tab for now (safer for testing)
|
||||
window.open(authUrl, '_blank');
|
||||
|
||||
// Show status message
|
||||
authButton.innerHTML = '✅ Check the new tab for authentication';
|
||||
authButton.disabled = true;
|
||||
});
|
||||
|
||||
// Check if we're returning from OAuth
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('code')) {
|
||||
console.log('OAuth callback detected:', urlParams.get('code'));
|
||||
document.querySelector('.simple-oauth').innerHTML = `
|
||||
<div class="oauth-success">
|
||||
✅ OAuth callback received!<br>
|
||||
<small>Code: ${urlParams.get('code')}</small><br>
|
||||
<small>In a full implementation, this would exchange the code for tokens.</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.simple-oauth {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oauth-button {
|
||||
background: #1185fe;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.oauth-button:hover {
|
||||
background: #0d6efd;
|
||||
}
|
||||
|
||||
.oauth-button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.oauth-note {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.oauth-success {
|
||||
background: #d1edff;
|
||||
border: 1px solid #b6d7ff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
color: #0c5460;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,93 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
|
||||
<div id="comment-atproto"></div>
|
||||
</article>
|
||||
|
||||
<aside class="article-sidebar">
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
<!-- TOC will be generated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Generate table of contents
|
||||
function generateTableOfContents() {
|
||||
const tocContainer = document.getElementById('toc-content');
|
||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
||||
|
||||
if (headings.length === 0) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
generateTableOfContents();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
@@ -1,21 +0,0 @@
|
||||
# Production environment variables
|
||||
VITE_APP_HOST=https://syui.ai
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
|
||||
# Base collection for OAuth app and ailog (all others are derived)
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
# [user, chat, chat.lang, chat.comment]
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
|
||||
|
||||
# API Configuration
|
||||
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ai.card</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "aicard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --mode development",
|
||||
"build": "vite build --mode production",
|
||||
"build:dev": "vite build --mode development",
|
||||
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/did": "^0.1.5",
|
||||
"@atproto/identity": "^0.4.8",
|
||||
"@atproto/oauth-client-browser": "^0.3.19",
|
||||
"@atproto/xrpc": "^0.7.0",
|
||||
"axios": "^1.6.2",
|
||||
"framer-motion": "^10.16.16",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
@@ -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://log.syui.ai/client-metadata.json",
|
||||
"client_name": "ai.card",
|
||||
"client_uri": "https://log.syui.ai",
|
||||
"logo_uri": "https://log.syui.ai/favicon.ico",
|
||||
"tos_uri": "https://log.syui.ai/terms",
|
||||
"policy_uri": "https://log.syui.ai/privacy",
|
||||
"redirect_uris": [
|
||||
"https://log.syui.ai/oauth/callback",
|
||||
"https://log.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
|
||||
}
|
||||
@@ -1,931 +0,0 @@
|
||||
/* Theme Colors */
|
||||
:root {
|
||||
--theme-color: #FF4500;
|
||||
--white: #fff;
|
||||
--light-gray: #aaa;
|
||||
--dark-gray: #666;
|
||||
--background: #fff;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 12px 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--theme-color);
|
||||
box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
|
||||
}
|
||||
|
||||
.nav-button.active:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 48px;
|
||||
margin: 0;
|
||||
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #6c757d;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-handle {
|
||||
color: #495057;
|
||||
font-weight: bold;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.login-button,
|
||||
.logout-button,
|
||||
.backup-button,
|
||||
.token-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.backup-button {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
|
||||
.token-button {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
|
||||
color: white;
|
||||
border: 1px solid #ffc107;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
color: #495057;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
|
||||
}
|
||||
|
||||
.backup-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
.token-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(108, 117, 125, 0.2);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-size: 24px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app .app-main {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 0px !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comment-section {
|
||||
padding: 0px !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
padding: 10px !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Fix overflow on article pages */
|
||||
article.article-content {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Ensure full width on mobile */
|
||||
.app {
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
/* Fix button overflow */
|
||||
button {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Fix comment-meta URI overflow */
|
||||
.comment-meta {
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
}
|
||||
|
||||
.gacha-section {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.gacha-section h2 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.gacha-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gacha-button {
|
||||
padding: 20px 40px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gacha-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.gacha-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gacha-button-premium {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gacha-button-premium::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
transparent 30%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
.collection-section h2 {
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 30px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
font-size: 18px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%) rotate(45deg); }
|
||||
100% { transform: translateX(100%) rotate(45deg); }
|
||||
}
|
||||
|
||||
/* Comment System Styles */
|
||||
.comment-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
/* padding: 20px; - removed to avoid double padding */
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.atproto-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.atproto-button:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
|
||||
}
|
||||
|
||||
.username-input-section {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.handle-input {
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
background: #e8f5e8;
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-section .user-info {
|
||||
position: static;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
|
||||
.user-details h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.user-section .user-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-section .user-handle {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #2e7d32;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.user-section .user-did {
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
background: #f1f3f4;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.comment-form h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comment-form textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.comment-form textarea:focus {
|
||||
border-color: #1185fe;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.post-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.post-button:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
|
||||
}
|
||||
|
||||
.post-button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.comments-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.comments-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comments-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comments-toggle-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.comments-toggle-button:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.comment-author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.comment-handle {
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: #c82333;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
padding: 8px;
|
||||
background: #f1f3f4;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.comment-meta small {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Admin Section Styles */
|
||||
.admin-section {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.admin-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #1976d2;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-list-form {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.user-list-form textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.user-list-form textarea:focus {
|
||||
border-color: #2196f3;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
.admin-hint {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* User List Records Styles */
|
||||
.user-list-records {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.user-list-records h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #1976d2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-user-lists {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-list-item {
|
||||
border: 1px solid #e3f2fd;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-list-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.user-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-list-date {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-list-content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.user-handles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-handle-tag {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pds-info {
|
||||
color: #666;
|
||||
font-size: 0.75em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.user-list-meta {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.user-list-meta small {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* JSON Display Styles */
|
||||
.json-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.json-button:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.json-display {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-display h5 {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: #f1f3f4;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 0.9em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #333;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Tab Navigation */
|
||||
.tab-navigation {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #e1e5e9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #656d76;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--theme-color);
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--theme-color);
|
||||
border-bottom-color: var(--theme-color);
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
/* AI Chat History */
|
||||
.ai-chat-list {
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.chat-type-text {
|
||||
font-size: 16px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
|
||||
.chat-date {
|
||||
color: #656d76;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
background: #f6f8fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #d1d9e0;
|
||||
margin-bottom: 8px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-meta {
|
||||
font-size: 11px;
|
||||
color: #656d76;
|
||||
}
|
||||
|
||||
.no-chat {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #656d76;
|
||||
font-style: italic;
|
||||
}
|
||||
1407
oauth/src/App.tsx
1407
oauth/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
// Cloudflare Access対応版の例
|
||||
const response = await fetch(`${aiConfig.host}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cloudflare Access Service Token
|
||||
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
|
||||
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 80,
|
||||
repeat_penalty: 1.1,
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User } from '../services/auth';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import { appConfig } 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 environment variables
|
||||
const aiConfig = {
|
||||
enabled: import.meta.env.VITE_AI_ENABLED === 'true',
|
||||
askAi: import.meta.env.VITE_AI_ASK_AI === 'true',
|
||||
provider: import.meta.env.VITE_AI_PROVIDER || 'ollama',
|
||||
model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b',
|
||||
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
|
||||
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
|
||||
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
|
||||
};
|
||||
|
||||
// Fetch AI profile on load
|
||||
useEffect(() => {
|
||||
const fetchAIProfile = async () => {
|
||||
console.log('=== AI PROFILE FETCH START ===');
|
||||
console.log('AI DID:', aiConfig.aiDid);
|
||||
|
||||
if (!aiConfig.aiDid) {
|
||||
console.log('No AI DID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try with agent first
|
||||
const agent = atprotoOAuthService.getAgent();
|
||||
if (agent) {
|
||||
console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid);
|
||||
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
|
||||
console.log('AI profile fetched successfully:', profile.data);
|
||||
const profileData = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profile.data.handle,
|
||||
displayName: profile.data.displayName,
|
||||
avatar: profile.data.avatar,
|
||||
description: profile.data.description
|
||||
};
|
||||
console.log('Setting aiProfile to:', profileData);
|
||||
setAiProfile(profileData);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
|
||||
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to public API
|
||||
console.log('No agent available, trying public API for AI profile');
|
||||
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
|
||||
if (response.ok) {
|
||||
const profileData = await response.json();
|
||||
console.log('AI profile fetched via public API:', profileData);
|
||||
const profile = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profileData.handle,
|
||||
displayName: profileData.displayName,
|
||||
avatar: profileData.avatar,
|
||||
description: profileData.description
|
||||
};
|
||||
console.log('Setting aiProfile to:', profile);
|
||||
setAiProfile(profile);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
|
||||
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
|
||||
return;
|
||||
} else {
|
||||
console.error('Public API failed with status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI profile:', error);
|
||||
setAiProfile(null);
|
||||
}
|
||||
console.log('=== AI PROFILE FETCH FAILED ===');
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
console.log('AIChat received question:', event.detail.question);
|
||||
console.log('Current aiProfile state:', aiProfile);
|
||||
|
||||
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);
|
||||
console.log('AIChat event listener registered');
|
||||
|
||||
// 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');
|
||||
|
||||
// 1. Post question to ATProto
|
||||
const now = new Date();
|
||||
const rkey = now.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
const questionRecord = {
|
||||
$type: appConfig.collections.chat,
|
||||
question: question,
|
||||
url: window.location.href,
|
||||
createdAt: now.toISOString(),
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
avatar: user.avatar,
|
||||
displayName: user.displayName || user.handle,
|
||||
},
|
||||
context: {
|
||||
page_title: document.title,
|
||||
page_url: window.location.href,
|
||||
},
|
||||
};
|
||||
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: appConfig.collections.chat,
|
||||
rkey: rkey,
|
||||
record: questionRecord,
|
||||
});
|
||||
|
||||
console.log('Question posted to ATProto');
|
||||
|
||||
// 2. Get chat history
|
||||
const chatRecords = await agent.api.com.atproto.repo.listRecords({
|
||||
repo: user.did,
|
||||
collection: appConfig.collections.chat,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
let chatHistoryText = '';
|
||||
if (chatRecords.data.records) {
|
||||
chatHistoryText = chatRecords.data.records
|
||||
.map((r: any) => {
|
||||
if (r.value.question) {
|
||||
return `User: ${r.value.question}`;
|
||||
} else if (r.value.answer) {
|
||||
return `AI: ${r.value.answer}`;
|
||||
}
|
||||
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',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 80, // Shorter responses for faster generation
|
||||
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';
|
||||
|
||||
console.log('=== SAVING AI ANSWER ===');
|
||||
console.log('Current aiProfile:', aiProfile);
|
||||
|
||||
const answerRecord = {
|
||||
$type: appConfig.collections.chat,
|
||||
answer: aiAnswer,
|
||||
question_rkey: rkey,
|
||||
url: window.location.href,
|
||||
createdAt: now.toISOString(),
|
||||
author: {
|
||||
did: aiProfile.did,
|
||||
handle: aiProfile.handle,
|
||||
displayName: aiProfile.displayName,
|
||||
avatar: aiProfile.avatar,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('Answer record to save:', answerRecord);
|
||||
|
||||
// Save to ATProto asynchronously (don't wait for it)
|
||||
agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: appConfig.collections.chat,
|
||||
rkey: answerRkey,
|
||||
record: answerRecord,
|
||||
}).catch(err => {
|
||||
console.error('Failed to save AI response to ATProto:', err);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate AI response:', 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) {
|
||||
console.error('Failed to fetch AI profile:', error);
|
||||
// Fallback to basic info
|
||||
setProfile({
|
||||
did: aiDid,
|
||||
handle: 'ai-assistant',
|
||||
displayName: 'AI Assistant',
|
||||
description: 'AI assistant for this blog',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (aiDid) {
|
||||
fetchAIProfile();
|
||||
}
|
||||
}, [aiDid]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="ai-profile-loading">Loading AI profile...</div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-profile">
|
||||
<div className="ai-avatar">
|
||||
{profile.avatar ? (
|
||||
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
|
||||
) : (
|
||||
<div className="ai-avatar-placeholder">🤖</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ai-info">
|
||||
<div className="ai-name">{profile.displayName || profile.handle}</div>
|
||||
<div className="ai-handle">@{profile.handle}</div>
|
||||
{profile.description && (
|
||||
<div className="ai-description">{profile.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card as CardType, CardRarity } from '../types/card';
|
||||
import '../styles/Card.css';
|
||||
|
||||
interface CardProps {
|
||||
card: CardType;
|
||||
isRevealing?: boolean;
|
||||
detailed?: boolean;
|
||||
}
|
||||
|
||||
const CARD_INFO: Record<number, { name: string; color: string }> = {
|
||||
0: { name: "アイ", color: "#fff700" },
|
||||
1: { name: "夢幻", color: "#b19cd9" },
|
||||
2: { name: "光彩", color: "#ffd700" },
|
||||
3: { name: "中性子", color: "#cacfd2" },
|
||||
4: { name: "太陽", color: "#ff6b35" },
|
||||
5: { name: "夜空", color: "#1a1a2e" },
|
||||
6: { name: "雪", color: "#e3f2fd" },
|
||||
7: { name: "雷", color: "#ffd93d" },
|
||||
8: { name: "超究", color: "#6c5ce7" },
|
||||
9: { name: "剣", color: "#a8e6cf" },
|
||||
10: { name: "破壊", color: "#ff4757" },
|
||||
11: { name: "地球", color: "#4834d4" },
|
||||
12: { name: "天の川", color: "#9c88ff" },
|
||||
13: { name: "創造", color: "#00d2d3" },
|
||||
14: { name: "超新星", color: "#ff9ff3" },
|
||||
15: { name: "世界", color: "#54a0ff" },
|
||||
};
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
|
||||
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
|
||||
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
|
||||
|
||||
const getRarityClass = () => {
|
||||
switch (card.status) {
|
||||
case CardRarity.UNIQUE:
|
||||
return 'card-unique';
|
||||
case CardRarity.KIRA:
|
||||
return 'card-kira';
|
||||
case CardRarity.SUPER_RARE:
|
||||
return 'card-super-rare';
|
||||
case CardRarity.RARE:
|
||||
return 'card-rare';
|
||||
default:
|
||||
return 'card-normal';
|
||||
}
|
||||
};
|
||||
|
||||
if (!detailed) {
|
||||
// Simple view - only image and frame
|
||||
return (
|
||||
<motion.div
|
||||
className={`card card-simple ${getRarityClass()}`}
|
||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
>
|
||||
<div className="card-frame">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={cardInfo.name}
|
||||
className="card-image-simple"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detailed view - all information
|
||||
return (
|
||||
<motion.div
|
||||
className={`card ${getRarityClass()}`}
|
||||
initial={isRevealing ? { rotateY: 180 } : {}}
|
||||
animate={isRevealing ? { rotateY: 0 } : {}}
|
||||
transition={{ duration: 0.8, type: "spring" }}
|
||||
style={{
|
||||
'--card-color': cardInfo.color,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="card-inner">
|
||||
<div className="card-header">
|
||||
<span className="card-id">#{card.id}</span>
|
||||
<span className="card-cp">CP: {card.cp}</span>
|
||||
</div>
|
||||
|
||||
<div className="card-image-container">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={cardInfo.name}
|
||||
className="card-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-content">
|
||||
<h3 className="card-name">{cardInfo.name}</h3>
|
||||
{card.is_unique && (
|
||||
<div className="unique-badge">UNIQUE</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{card.skill && (
|
||||
<div className="card-skill">
|
||||
<p>{card.skill}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
<span className="card-rarity">{card.status.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import { Card } from './Card';
|
||||
import '../styles/CardBox.css';
|
||||
|
||||
interface CardBoxProps {
|
||||
userDid: string;
|
||||
}
|
||||
|
||||
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
|
||||
const [boxData, setBoxData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadBoxData();
|
||||
}, [userDid]);
|
||||
|
||||
const loadBoxData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await atprotoOAuthService.getCardsFromBox();
|
||||
setBoxData(data);
|
||||
} catch (err) {
|
||||
console.error('カードボックス読み込みエラー:', err);
|
||||
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) {
|
||||
console.error('カードボックス削除エラー:', err);
|
||||
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) {
|
||||
console.error('Error loading card master data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load card data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<div className="loading">Loading card data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<div className="error">Error: {error}</div>
|
||||
<button onClick={loadMasterData}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create cards for all rarity patterns
|
||||
const rarityPatterns = ['normal', 'unique'] as const;
|
||||
|
||||
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
|
||||
|
||||
masterData.forEach(data => {
|
||||
rarityPatterns.forEach(pattern => {
|
||||
const card: CardType = {
|
||||
id: data.id,
|
||||
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
|
||||
status: pattern,
|
||||
skill: null,
|
||||
owner_did: 'sample',
|
||||
obtained_at: new Date().toISOString(),
|
||||
is_unique: pattern === 'unique',
|
||||
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
|
||||
};
|
||||
displayCards.push({
|
||||
card,
|
||||
data,
|
||||
patternName: `${data.id}-${pattern}`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className="card-list-container">
|
||||
<header className="card-list-header">
|
||||
<h1>ai.card マスターリスト</h1>
|
||||
<p>全カード・全レアリティパターン表示</p>
|
||||
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
|
||||
</header>
|
||||
|
||||
<div className="card-list-simple-grid">
|
||||
{displayCards.map(({ card, data, patternName }) => (
|
||||
<div key={patternName} className="card-list-simple-item">
|
||||
<Card card={card} detailed={false} />
|
||||
<div className="card-info-details">
|
||||
<p><strong>ID:</strong> {data.id}</p>
|
||||
<p><strong>Name:</strong> {data.name}</p>
|
||||
<p><strong>日本語名:</strong> {data.ja_name}</p>
|
||||
<p><strong>レアリティ:</strong> {card.status}</p>
|
||||
<p><strong>CP:</strong> {card.cp}</p>
|
||||
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
|
||||
{data.description && (
|
||||
<p className="card-description">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { aiCardApi } from '../services/api';
|
||||
import '../styles/CollectionAnalysis.css';
|
||||
|
||||
interface AnalysisData {
|
||||
total_cards: number;
|
||||
unique_cards: number;
|
||||
rarity_distribution: Record<string, number>;
|
||||
collection_score: number;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
interface CollectionAnalysisProps {
|
||||
userDid: string;
|
||||
}
|
||||
|
||||
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
|
||||
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = async () => {
|
||||
if (!userDid) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await aiCardApi.analyzeCollection(userDid);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
console.error('Collection analysis failed:', err);
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
console.error('保存エラー:', error);
|
||||
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) {
|
||||
console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
|
||||
setUseAI(false);
|
||||
result = await cardApi.getGachaStats();
|
||||
}
|
||||
} else {
|
||||
result = await cardApi.getGachaStats();
|
||||
}
|
||||
setStats(result);
|
||||
} catch (err) {
|
||||
console.error('Gacha stats failed:', err);
|
||||
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="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>
|
||||
);
|
||||
};
|
||||
@@ -1,253 +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 }) => {
|
||||
console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
|
||||
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(() => {
|
||||
console.error('OAuth callback timeout');
|
||||
onError('OAuth認証がタイムアウトしました');
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
const handleCallback = async () => {
|
||||
console.log('=== HANDLE CALLBACK STARTED ===');
|
||||
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');
|
||||
|
||||
console.log('OAuth callback parameters:', {
|
||||
code: code ? code.substring(0, 20) + '...' : null,
|
||||
state: state,
|
||||
error: error,
|
||||
iss: iss,
|
||||
hash: window.location.hash,
|
||||
search: window.location.search
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
|
||||
|
||||
// Use the official BrowserOAuthClient to handle the callback
|
||||
const result = await atprotoOAuthService.handleOAuthCallback();
|
||||
if (result) {
|
||||
console.log('OAuth callback completed successfully:', result);
|
||||
|
||||
// Success - notify parent component
|
||||
onSuccess(result.did, result.handle);
|
||||
} else {
|
||||
throw new Error('OAuth callback did not return a session');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth callback error:', error);
|
||||
|
||||
// Even if OAuth fails, try to continue with a fallback approach
|
||||
console.warn('OAuth callback failed, attempting fallback...');
|
||||
|
||||
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) {
|
||||
console.error('Fallback also failed:', 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) {
|
||||
console.log('Handle is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting handle:', trimmedHandle);
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Resolve DID from handle
|
||||
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
|
||||
console.log('Resolved DID:', did);
|
||||
|
||||
// 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) {
|
||||
console.error('Failed to resolve DID:', 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) => {
|
||||
console.log('Input changed:', e.target.value);
|
||||
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,42 +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(() => {
|
||||
console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
console.log('Search params:', window.location.search);
|
||||
console.log('Pathname:', window.location.pathname);
|
||||
}, []);
|
||||
|
||||
const handleSuccess = (did: string, handle: string) => {
|
||||
console.log('OAuth success, redirecting to home:', { did, handle });
|
||||
|
||||
// Add a small delay to ensure state is properly updated
|
||||
setTimeout(() => {
|
||||
navigate('/', { replace: true });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
console.error('OAuth error, redirecting to home:', error);
|
||||
|
||||
// Add a small delay before redirect
|
||||
setTimeout(() => {
|
||||
navigate('/', { replace: true });
|
||||
}, 2000); // Give user time to see error
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Processing OAuth callback...</h2>
|
||||
<OAuthCallback
|
||||
onSuccess={handleSuccess}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
// Application configuration
|
||||
export interface AppConfig {
|
||||
adminDid: 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;
|
||||
bskyPublicApi: string;
|
||||
}
|
||||
|
||||
// Collection name builders (similar to Rust implementation)
|
||||
export function getCollectionNames(base: string) {
|
||||
return {
|
||||
comment: base,
|
||||
user: `${base}.user`,
|
||||
chat: `${base}.chat`,
|
||||
chatLang: `${base}.chat.lang`,
|
||||
chatComment: `${base}.chat.comment`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
return reversedParts.join('.');
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate collection base from host:', host, error);
|
||||
// Fallback to default
|
||||
return 'ai.syui.log';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract rkey from current URL
|
||||
// /posts/xxx -> xxx
|
||||
function extractRkeyFromUrl(): string | undefined {
|
||||
const pathname = window.location.pathname;
|
||||
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
// Get application configuration from environment variables
|
||||
export function getAppConfig(): AppConfig {
|
||||
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
|
||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
||||
|
||||
// Priority: Environment variables > Auto-generated from host
|
||||
const autoGeneratedBase = generateBaseCollectionFromHost(host);
|
||||
const collections = {
|
||||
base: import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase,
|
||||
};
|
||||
|
||||
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 || 'gemma2:2b';
|
||||
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
|
||||
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
|
||||
|
||||
console.log('App configuration:', {
|
||||
host,
|
||||
adminDid,
|
||||
collections,
|
||||
rkey: rkey || 'none (not on post page)',
|
||||
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost },
|
||||
bskyPublicApi
|
||||
});
|
||||
|
||||
return {
|
||||
adminDid,
|
||||
collections,
|
||||
host,
|
||||
rkey,
|
||||
aiEnabled,
|
||||
aiAskAi,
|
||||
aiProvider,
|
||||
aiModel,
|
||||
aiHost,
|
||||
bskyPublicApi
|
||||
};
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const appConfig = getAppConfig();
|
||||
@@ -1,30 +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');
|
||||
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
|
||||
|
||||
mountPoints.forEach((mountPoint, index) => {
|
||||
console.log(`Mounting React app to comment-atproto #${index + 1}`);
|
||||
ReactDOM.createRoot(mountPoint as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
||||
<Route path="/list" element={<CardList />} />
|
||||
<Route path="*" element={<App />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -1,107 +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) {
|
||||
console.warn('ai.gpt AI分析機能が利用できません:', 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) {
|
||||
console.warn('ai.gpt AI統計機能が利用できません:', 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,686 +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 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 {
|
||||
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
|
||||
|
||||
// Generate client ID based on current origin
|
||||
const clientId = this.getClientId();
|
||||
console.log('Client ID:', clientId);
|
||||
|
||||
// Support multiple PDS hosts for OAuth
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://bsky.social', // Default resolver
|
||||
});
|
||||
|
||||
console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
|
||||
|
||||
// Try to restore existing session
|
||||
const result = await this.oauthClient.init();
|
||||
if (result?.session) {
|
||||
console.log('Existing session restored:', {
|
||||
did: result.session.did,
|
||||
handle: result.session.handle || 'unknown',
|
||||
hasAccessJwt: !!result.session.accessJwt,
|
||||
hasRefreshJwt: !!result.session.refreshJwt
|
||||
});
|
||||
|
||||
// Create Agent instance with proper configuration
|
||||
console.log('Creating Agent with session:', result.session);
|
||||
|
||||
// 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
|
||||
console.log('Full session object:', result.session);
|
||||
console.log('Session type:', typeof result.session);
|
||||
console.log('Session constructor:', result.session?.constructor?.name);
|
||||
|
||||
// Try to iterate over the session object
|
||||
if (result.session) {
|
||||
console.log('Session properties:');
|
||||
for (const key in result.session) {
|
||||
console.log(` ${key}:`, result.session[key]);
|
||||
}
|
||||
|
||||
// Check if session has methods
|
||||
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
|
||||
console.log('Session methods:', methods);
|
||||
}
|
||||
|
||||
// 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);
|
||||
console.log('Session processed during initialization:', sessionData);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('No existing session found');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OAuth client:', error);
|
||||
this.initializePromise = null; // Reset on error to allow retry
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async processSession(session: any): Promise<{ did: string; handle: string }> {
|
||||
console.log('Processing session:', session);
|
||||
|
||||
// Log full session structure
|
||||
console.log('Session structure:');
|
||||
console.log('- sub:', session.sub);
|
||||
console.log('- did:', session.did);
|
||||
console.log('- handle:', session.handle);
|
||||
console.log('- iss:', session.iss);
|
||||
console.log('- aud:', session.aud);
|
||||
|
||||
// Check if agent has properties we can access
|
||||
if (session.agent) {
|
||||
console.log('- agent:', session.agent);
|
||||
console.log('- agent.did:', session.agent?.did);
|
||||
console.log('- agent.handle:', session.agent?.handle);
|
||||
}
|
||||
|
||||
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);
|
||||
console.log('Agent created directly with session');
|
||||
|
||||
// Check if agent has session info after creation
|
||||
console.log('Agent after creation:');
|
||||
console.log('- agent.did:', this.agent.did);
|
||||
console.log('- agent.session:', this.agent.session);
|
||||
if (this.agent.session) {
|
||||
console.log('- agent.session.did:', this.agent.session.did);
|
||||
console.log('- agent.session.handle:', this.agent.session.handle);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to create Agent with session directly, trying dpopFetch method');
|
||||
// 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') {
|
||||
console.log('Handle not in session, attempting to resolve...');
|
||||
|
||||
// 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;
|
||||
console.log('Successfully resolved handle via getProfile:', handle);
|
||||
return { did, handle };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('getProfile failed:', 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;
|
||||
console.log('Got handle from describeRepo:', handle);
|
||||
return { did, handle };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('describeRepo failed:', err);
|
||||
}
|
||||
|
||||
// Method 3: Hardcoded fallback for known DIDs
|
||||
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
|
||||
handle = 'syui.ai';
|
||||
(this as any)._sessionInfo.handle = handle;
|
||||
console.log('Using hardcoded handle for known DID');
|
||||
}
|
||||
}
|
||||
|
||||
return { did, handle };
|
||||
}
|
||||
|
||||
private getClientId(): string {
|
||||
// Use environment variable if available
|
||||
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
||||
if (envClientId) {
|
||||
console.log('Using client ID from environment:', 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')) {
|
||||
console.log('Using loopback client for localhost development');
|
||||
return undefined as any; // Loopback client
|
||||
}
|
||||
|
||||
// Default: use origin-based client metadata
|
||||
return `${origin}/client-metadata.json`;
|
||||
}
|
||||
|
||||
private detectPDSFromHandle(handle: string): string {
|
||||
console.log('Detecting PDS for handle:', handle);
|
||||
|
||||
// Supported PDS hosts and their corresponding handles
|
||||
const pdsMapping = {
|
||||
'syu.is': 'https://syu.is',
|
||||
'bsky.social': 'https://bsky.social',
|
||||
};
|
||||
|
||||
// Check if handle ends with known PDS domains
|
||||
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
|
||||
if (handle.endsWith(`.${domain}`)) {
|
||||
console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`);
|
||||
return pdsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to bsky.social
|
||||
console.log(`Handle ${handle} using default PDS: https://bsky.social`);
|
||||
return 'https://bsky.social';
|
||||
}
|
||||
|
||||
async initiateOAuthFlow(handle?: string): Promise<void> {
|
||||
try {
|
||||
console.log('=== INITIATING OAUTH FLOW ===');
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('OAuth client not initialized, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Starting OAuth flow for handle:', handle);
|
||||
|
||||
// Detect PDS based on handle
|
||||
const pdsUrl = this.detectPDSFromHandle(handle);
|
||||
console.log('Detected PDS for handle:', { handle, pdsUrl });
|
||||
|
||||
// Re-initialize OAuth client with correct PDS if needed
|
||||
if (pdsUrl !== 'https://bsky.social') {
|
||||
console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: this.getClientId(),
|
||||
handleResolver: pdsUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Start OAuth authorization flow
|
||||
console.log('Calling oauthClient.authorize with handle:', handle);
|
||||
|
||||
try {
|
||||
const authUrl = await this.oauthClient.authorize(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
});
|
||||
|
||||
console.log('Authorization URL generated:', authUrl.toString());
|
||||
console.log('URL breakdown:', {
|
||||
protocol: authUrl.protocol,
|
||||
hostname: authUrl.hostname,
|
||||
pathname: authUrl.pathname,
|
||||
search: authUrl.search
|
||||
});
|
||||
|
||||
// Store some debug info before redirect
|
||||
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
handle: handle,
|
||||
authUrl: authUrl.toString(),
|
||||
currentUrl: window.location.href
|
||||
}));
|
||||
|
||||
// Redirect to authorization server
|
||||
console.log('About to redirect to:', authUrl.toString());
|
||||
window.location.href = authUrl.toString();
|
||||
} catch (authorizeError) {
|
||||
console.error('oauthClient.authorize failed:', authorizeError);
|
||||
console.error('Error details:', {
|
||||
name: authorizeError.name,
|
||||
message: authorizeError.message,
|
||||
stack: authorizeError.stack
|
||||
});
|
||||
throw authorizeError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate OAuth flow:', error);
|
||||
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
console.log('=== HANDLING OAUTH CALLBACK ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
console.log('URL hash:', window.location.hash);
|
||||
console.log('URL search:', window.location.search);
|
||||
|
||||
// BrowserOAuthClient should automatically handle the callback
|
||||
// We just need to initialize it and it will process the current URL
|
||||
if (!this.oauthClient) {
|
||||
console.log('OAuth client not initialized, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
}
|
||||
|
||||
console.log('OAuth client ready, initializing to process callback...');
|
||||
|
||||
// Call init() again to process the callback URL
|
||||
const result = await this.oauthClient.init();
|
||||
console.log('OAuth callback processing result:', result);
|
||||
|
||||
if (result?.session) {
|
||||
// Process the session
|
||||
return this.processSession(result.session);
|
||||
}
|
||||
|
||||
// If no session yet, wait a bit and try again
|
||||
console.log('No session found immediately, waiting...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Try to check session again
|
||||
const sessionCheck = await this.checkSession();
|
||||
if (sessionCheck) {
|
||||
console.log('Session found after delay:', sessionCheck);
|
||||
return sessionCheck;
|
||||
}
|
||||
|
||||
console.warn('OAuth callback completed but no session was created');
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth callback handling failed:', error);
|
||||
console.error('Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async checkSession(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
console.log('=== CHECK SESSION CALLED ===');
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('No OAuth client, initializing...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('OAuth client initialization failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Running oauthClient.init() to check session...');
|
||||
const result = await this.oauthClient.init();
|
||||
console.log('oauthClient.init() result:', result);
|
||||
|
||||
if (result?.session) {
|
||||
// Use the common session processing method
|
||||
return this.processSession(result.session);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getAgent(): Agent | null {
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
getSession(): AtprotoSession | null {
|
||||
console.log('getSession called');
|
||||
console.log('Current state:', {
|
||||
hasAgent: !!this.agent,
|
||||
hasAgentSession: !!this.agent?.session,
|
||||
hasOAuthClient: !!this.oauthClient,
|
||||
hasSessionInfo: !!(this as any)._sessionInfo
|
||||
});
|
||||
|
||||
// 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 || '',
|
||||
};
|
||||
console.log('Returning agent session:', session);
|
||||
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',
|
||||
};
|
||||
console.log('Returning stored session info:', session);
|
||||
return session;
|
||||
}
|
||||
|
||||
console.log('No session available');
|
||||
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 {
|
||||
console.log('=== LOGGING OUT ===');
|
||||
|
||||
// Clear Agent
|
||||
this.agent = null;
|
||||
console.log('Agent cleared');
|
||||
|
||||
// Clear BrowserOAuthClient session
|
||||
if (this.oauthClient) {
|
||||
console.log('Clearing OAuth client session...');
|
||||
try {
|
||||
// BrowserOAuthClient may have a revoke or signOut method
|
||||
if (typeof (this.oauthClient as any).signOut === 'function') {
|
||||
await (this.oauthClient as any).signOut();
|
||||
console.log('OAuth client signed out');
|
||||
} else if (typeof (this.oauthClient as any).revoke === 'function') {
|
||||
await (this.oauthClient as any).revoke();
|
||||
console.log('OAuth client revoked');
|
||||
} else {
|
||||
console.log('No explicit signOut method found on OAuth client');
|
||||
}
|
||||
} catch (oauthError) {
|
||||
console.error('OAuth client logout error:', oauthError);
|
||||
}
|
||||
|
||||
// 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 localStorage items that might be related to OAuth
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => {
|
||||
console.log('Removing localStorage key:', key);
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
|
||||
console.log('=== LOGOUT COMPLETED ===');
|
||||
|
||||
// Force page reload to ensure clean state
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', 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 {
|
||||
console.log('Saving cards to atproto collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
console.log('PutRecord request:', {
|
||||
repo: did,
|
||||
collection: collection,
|
||||
rkey: rkey,
|
||||
record: record
|
||||
});
|
||||
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
console.log('カードデータをai.card.boxに保存しました:', response);
|
||||
} catch (error) {
|
||||
console.error('カードボックス保存エラー:', 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 {
|
||||
console.log('Fetching cards from atproto collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// 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'
|
||||
});
|
||||
|
||||
console.log('Cards from box response:', response);
|
||||
|
||||
// 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) {
|
||||
console.error('カードボックス取得エラー:', 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 {
|
||||
console.log('Deleting card box collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// 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'
|
||||
});
|
||||
|
||||
console.log('Card box deleted successfully:', response);
|
||||
} catch (error) {
|
||||
console.error('カードボックス削除エラー:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 手動でトークンを設定(開発・デバッグ用)
|
||||
setManualTokens(accessJwt: string, refreshJwt: string): void {
|
||||
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
|
||||
console.warn('Please use the proper OAuth flow instead');
|
||||
|
||||
// For backward compatibility, store in localStorage
|
||||
const session: AtprotoSession = {
|
||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
handle: 'syui.ai',
|
||||
accessJwt: accessJwt,
|
||||
refreshJwt: refreshJwt
|
||||
};
|
||||
|
||||
localStorage.setItem('atproto_session', JSON.stringify(session));
|
||||
console.log('Manual tokens stored in localStorage for backward compatibility');
|
||||
}
|
||||
|
||||
// 後方互換性のための従来関数
|
||||
saveSessionToStorage(session: AtprotoSession): void {
|
||||
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
|
||||
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 };
|
||||
@@ -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,24 +0,0 @@
|
||||
export enum CardRarity {
|
||||
NORMAL = "normal",
|
||||
RARE = "rare",
|
||||
SUPER_RARE = "super_rare",
|
||||
KIRA = "kira",
|
||||
UNIQUE = "unique"
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: number;
|
||||
cp: number;
|
||||
status: CardRarity;
|
||||
skill?: string;
|
||||
owner_did: string;
|
||||
obtained_at: string;
|
||||
is_unique: boolean;
|
||||
unique_id?: string;
|
||||
}
|
||||
|
||||
export interface CardDrawResult {
|
||||
card: Card;
|
||||
is_new: boolean;
|
||||
animation_type: string;
|
||||
}
|
||||
@@ -1,141 +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) {
|
||||
console.error('Failed to generate JWKS:', 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
|
||||
console.debug('URL parsing failed, passing through:', e);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
navigator.serviceWorker.register(swUrl).catch(console.error);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +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) {
|
||||
console.warn('Failed to load stored key, generating new one:', 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) {
|
||||
console.error('Failed to store private key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
|
||||
const privateKey = await window.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
keyData,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign']
|
||||
);
|
||||
|
||||
// Derive public key from private key
|
||||
const publicKeyData = { ...keyData };
|
||||
delete publicKeyData.d; // Remove private component
|
||||
|
||||
const publicKey = await window.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
publicKeyData,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['verify']
|
||||
);
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored keys (for testing/reset)
|
||||
*/
|
||||
static clearKeys(): void {
|
||||
localStorage.removeItem('oauth_private_key');
|
||||
this.keyPair = null;
|
||||
this.jwks = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic client metadata based on current URL
|
||||
*/
|
||||
export function generateClientMetadata(): any {
|
||||
// Use environment variables if available, fallback to current origin
|
||||
const host = import.meta.env.VITE_APP_HOST || window.location.origin;
|
||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
|
||||
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
|
||||
|
||||
return {
|
||||
client_id: clientId,
|
||||
client_name: 'ai.card',
|
||||
client_uri: host,
|
||||
logo_uri: `${host}/favicon.ico`,
|
||||
tos_uri: `${host}/terms`,
|
||||
policy_uri: `${host}/privacy`,
|
||||
redirect_uris: [redirectUri, host],
|
||||
response_types: ['code'],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_method: 'private_key_jwt',
|
||||
token_endpoint_auth_signing_alg: 'ES256',
|
||||
scope: 'atproto transition:generic',
|
||||
subject_type: 'public',
|
||||
application_type: 'web',
|
||||
dpop_bound_access_tokens: true,
|
||||
jwks_uri: `${host}/.well-known/jwks.json`
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
// Custom plugin to replace variables in public files during build
|
||||
{
|
||||
name: 'replace-env-vars',
|
||||
writeBundle() {
|
||||
const host = env.VITE_APP_HOST || 'https://log.syui.ai'
|
||||
const clientId = env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`
|
||||
const redirectUri = env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`
|
||||
|
||||
// Replace variables in client-metadata.json
|
||||
const clientMetadataPath = path.resolve(__dirname, 'dist/client-metadata.json')
|
||||
if (fs.existsSync(clientMetadataPath)) {
|
||||
let content = fs.readFileSync(clientMetadataPath, 'utf-8')
|
||||
content = content.replace(/https:\/\/log\.syui\.ai/g, host)
|
||||
fs.writeFileSync(clientMetadataPath, content)
|
||||
console.log(`Updated client-metadata.json with host: ${host}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
// Generate standalone index.html for testing
|
||||
{
|
||||
name: 'generate-standalone-html',
|
||||
writeBundle(options, bundle) {
|
||||
// Find actual generated filenames
|
||||
const jsFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.js'))
|
||||
const cssFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.css'))
|
||||
|
||||
// Generate minimal index.html with just asset references
|
||||
const indexHtmlPath = path.resolve(__dirname, 'dist/index.html')
|
||||
const indexHtmlContent = `<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/${jsFile}"></script>
|
||||
<link rel="stylesheet" crossorigin href="/${cssFile}">`
|
||||
fs.writeFileSync(indexHtmlPath, indexHtmlContent)
|
||||
console.log('Generated minimal index.html with asset references')
|
||||
}
|
||||
}
|
||||
],
|
||||
build: {
|
||||
// Keep console.log in production for debugging
|
||||
minify: 'esbuild',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Hash-based filenames to bust cache
|
||||
entryFileNames: 'assets/comment-atproto-[hash].js',
|
||||
chunkFileNames: 'assets/comment-atproto-[name]-[hash].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||
return 'assets/comment-atproto-[hash].css';
|
||||
}
|
||||
return 'assets/[name]-[hash].[ext]';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
drop: [], // Don't drop console.log
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '127.0.0.1',
|
||||
allowedHosts: ['localhost', '127.0.0.1', 'log.syui.ai'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
},
|
||||
// Handle OAuth callback routing
|
||||
historyApiFallback: {
|
||||
rewrites: [
|
||||
{ from: /^\/oauth\/callback/, to: '/index.html' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user