30 Commits

Author SHA1 Message Date
f52249c292 Fix GitHub Actions permissions for release creation
- Add contents: write permission for private repository
- Remove Windows zip file references
- Simplify artifact upload paths
- Fix release workflow permissions for private repos

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 16:53:06 +09:00
aa70183d75 Fix cross-compilation issues and optimize release workflow
- Fix ARM64 cross-compilation with proper binutils
- Use target-specific strip commands
- Remove Windows/musl builds to reduce complexity
- Add 60-minute timeout for builds
- Optimize to 4 core platforms: Linux x86_64/ARM64, macOS Intel/ARM

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 16:42:28 +09:00
4ad1d3edf6 Fix cross-compilation issues and private repo access
- Replace native-tls with rustls-tls for cross-platform compatibility
- Add vendored OpenSSL/libgit2 features for static linking
- Add connect feature to tokio-tungstenite
- Add GITHUB_TOKEN authentication for private repo access
- Add smart binary caching with version checking workflow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 16:26:36 +09:00
55bf725491 Add GitHub Actions workflows and optimize build performance
- Add release.yml for multi-platform binary builds (Linux, macOS, Windows)
- Add gh-pages-fast.yml for fast deployment using pre-built binaries
- Add build-binary.yml for standalone binary artifact creation
- Optimize Cargo.toml with build profiles and reduced tokio features
- Remove 26MB of unused Font Awesome assets (kept only essential files)
- Font Awesome reduced from 28MB to 1.2MB

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 16:12:05 +09:00
13f1785081 update 2025-06-14 15:56:25 +09:00
bb6d51a602 fix css 2025-06-14 13:17:09 +09:00
a4114c5be3 fix theme 2025-06-14 13:17:09 +09:00
5c13dc0a1c fix readme 2025-06-14 13:17:09 +09:00
cef0675a88 add system 2025-06-14 13:17:09 +09:00
fd223290df code layout 2025-06-14 13:17:09 +09:00
5f4382911b fix command build 2025-06-14 13:17:09 +09:00
95cee69482 add github 2025-06-14 13:17:08 +09:00
33c166fa0c fix color 2025-06-14 13:17:08 +09:00
36863e4d9f fix loading 2025-06-14 13:17:08 +09:00
fb0e5107cf add ask AI 2025-06-14 13:17:08 +09:00
962017f922 update readme
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m41s
2025-06-12 20:04:57 +09:00
5ce03098bd fix stream env
Some checks are pending
Deploy ailog / build-and-deploy (push) Waiting to run
2025-06-12 19:59:19 +09:00
acce1d5af3 fix zsh
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 14m36s
2025-06-12 19:23:01 +09:00
bf0b72a52d rm oauth-env
Some checks are pending
Deploy ailog / build-and-deploy (push) Waiting to run
2025-06-12 19:12:55 +09:00
6e6c6e2f53 fix oauth
Some checks failed
Deploy ailog / build-and-deploy (push) Has been cancelled
2025-06-12 19:12:33 +09:00
eb5aa0a2be fix cargo
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 14m42s
2025-06-11 18:27:58 +09:00
ad45b151b1 fix env
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 11m20s
2025-06-11 17:31:21 +09:00
4775fa7034 fix ui
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 10m58s
2025-06-11 17:01:41 +09:00
d396dbd052 fix oauth
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m51s
2025-06-11 16:24:48 +09:00
ec3e3d1f89 fix run 2025-06-11 15:58:41 +09:00
b2fa06d5fa fix config vite
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 11m33s
2025-06-11 15:16:06 +09:00
bebd6a61eb fix xxxcard log
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m39s
2025-06-11 13:20:01 +09:00
4fe0582c6b fix readme
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m55s
2025-06-11 12:54:23 +09:00
637028c264 add oauth
Some checks are pending
Deploy ailog / build-and-deploy (push) Waiting to run
2025-06-11 12:53:31 +09:00
c0e4dc63ea update gpt 2025-06-06 03:18:20 +09:00
150 changed files with 17627 additions and 844 deletions

View File

@ -10,7 +10,42 @@
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(pkill:*)"
"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:*)"
],
"deny": []
}

View File

@ -0,0 +1,53 @@
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build ailog
run: |
cargo build --release
- name: Build OAuth app
run: |
cd oauth
npm install
npm run build
- name: Copy OAuth assets
run: |
cp -r oauth/dist/* my-blog/static/
- name: Generate site with ailog
run: |
./target/release/ailog generate --input content --output 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: syui-ai
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,28 @@
name: Example ailog usage
on:
workflow_dispatch: # Manual trigger for testing
jobs:
build-with-ailog-action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build with ailog action
uses: ai/log@v1 # This will reference this repository
with:
content-dir: 'content'
output-dir: 'public'
ai-integration: true
atproto-integration: true
- 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: public

51
.github/workflows/build-binary.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Build Binary
on:
workflow_dispatch: # Manual trigger
push:
branches: [ main ]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build binary
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ailog-linux
path: target/release/ailog
retention-days: 30

66
.github/workflows/cloudflare-pages.yml vendored Normal file
View File

@ -0,0 +1,66 @@
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 Node.js
uses: actions/setup-node@v4
with:
node-version: '21'
- name: Install dependencies
run: |
cd oauth
npm install
- name: Build OAuth app
run: |
cd oauth
npm run build
- name: Copy OAuth build to static
run: |
mkdir -p my-blog/static/assets
cp -r oauth/dist/assets/* my-blog/static/assets/
cp oauth/dist/index.html my-blog/static/oauth/index.html || true
- 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'

77
.github/workflows/gh-pages-fast.yml vendored Normal file
View File

@ -0,0 +1,77 @@
name: github pages (fast)
on:
push:
branches:
- main
paths-ignore:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Check and update ailog binary
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get latest release version
LATEST_VERSION=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)
echo "Latest version: $LATEST_VERSION"
# Check current binary version if exists
mkdir -p ./bin
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version | awk '{print $2}' || echo "unknown")
echo "Current version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Download if version is different or binary doesn't exist
if [ "$CURRENT_VERSION" != "${LATEST_VERSION#v}" ]; then
echo "Downloading ailog $LATEST_VERSION..."
curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" \
https://github.com/${{ github.repository }}/releases/download/$LATEST_VERSION/ailog-linux-x86_64.tar.gz | tar -xzf -
mv ailog ./bin/ailog
chmod +x ./bin/ailog
echo "Updated to version: $(./bin/ailog --version)"
else
echo "Binary is up to date"
chmod +x ./bin/ailog
fi
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build with ailog
env:
TZ: "Asia/Tokyo"
run: |
# Use pre-built ailog binary instead of cargo build
./bin/ailog build --output ./public
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages

169
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,169 @@
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
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag_name.outputs.tag }}
name: ailog ${{ steps.tag_name.outputs.tag }}
body_path: release_notes.md
draft: false
prerelease: ${{ contains(steps.tag_name.outputs.tag, 'alpha') || contains(steps.tag_name.outputs.tag, 'beta') || contains(steps.tag_name.outputs.tag, 'rc') }}
files: artifacts/*/ailog-*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@ -4,4 +4,10 @@
*.swp
*.swo
*~
.DS_Store
.DS_Store
cloudflare-config.yml
my-blog/public/
dist
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*

View File

@ -6,12 +6,16 @@ authors = ["syui"]
description = "A static blog generator with AI features"
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 = ["full"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
anyhow = "1.0"
toml = "0.8"
chrono = "0.4"
@ -22,7 +26,7 @@ fs_extra = "1.3"
colored = "2.1"
serde_yaml = "0.9"
syntect = "5.2"
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
@ -32,6 +36,45 @@ 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"
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

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# 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"]

664
README.md
View File

@ -1,18 +1,233 @@
# ai.log
A Rust-based static blog generator with AI integration capabilities.
AI-powered static blog generator with ATProto integration, part of the ai.ai ecosystem.
## Overview
## 🎯 Gitea Action Usage
ai.log is part of the ai ecosystem - a static site generator that creates blogs with built-in AI features for content enhancement and atproto integration.
Use ailog in your Gitea Actions workflow:
```yaml
name: Deploy Blog
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ai/log@v1
with:
content-dir: 'content'
output-dir: 'public'
ai-integration: true
atproto-integration: true
- uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-blog
directory: public
```
## 🚀 Quick Start
### Development Setup
```bash
# 1. Clone and setup
git clone https://git.syui.ai/ai/log
cd log
# 2. Start development services
./run.zsh serve # Blog development server
./run.zsh c # Cloudflare tunnel (example.com)
./run.zsh o # OAuth web server
./run.zsh co # Comment system monitor
# 3. Start Ollama (for Ask AI)
brew install ollama
ollama pull gemma2:2b
OLLAMA_ORIGINS="https://example.com" ollama serve
```
### Production Deployment
```bash
# 1. Build static site
hugo
# 2. Deploy to GitHub Pages
git add .
git commit -m "Update blog"
git push origin main
# 3. Automatic deployment via GitHub Actions
# Site available at: https://yourusername.github.io/repo-name
```
### ATProto Integration
```bash
# 1. OAuth Client Setup (oauth/client-metadata.json)
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "ai.log Blog System",
"redirect_uris": ["https://example.com/oauth/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"application_type": "web",
"dpop_bound_access_tokens": true
}
# 2. Comment System Configuration
# Collection: ai.syui.log (comments)
# User Management: ai.syui.log.user (registered users)
# 3. Services
./run.zsh o # OAuth authentication server
./run.zsh co # ATProto Jetstream comment monitor
```
### Development with run.zsh
```bash
# Development
./run.zsh serve
# Production (with Cloudflare Tunnel)
./run.zsh tunnel
# OAuth app development
./run.zsh o
# Comment system monitoring
./run.zsh co
```
## 📋 Commands
| Command | Description |
|---------|-------------|
| `./run.zsh c` | Enable Cloudflare tunnel (example.com) for OAuth |
| `./run.zsh o` | Start OAuth web server (port:4173 = example.com) |
| `./run.zsh co` | Start comment system (ATProto stream monitor) |
## 🏗️ Architecture (Pure Rust + HTML + JS)
```
ai.log/
├── oauth/ # 🎯 OAuth files (protected)
│ ├── oauth-widget-simple.js # Self-contained OAuth widget
│ ├── oauth-simple.html # OAuth authentication page
│ ├── client-metadata.json # ATProto configuration
│ └── README.md # Usage guide
├── my-blog/ # Blog content and templates
│ ├── content/posts/ # Markdown blog posts
│ ├── templates/ # Tera templates
│ ├── static/ # Static assets (OAuth copied here)
│ └── public/ # Generated site (build output)
├── src/ # Rust blog generator
├── scripts/ # Build and deployment scripts
└── run.zsh # 🎯 Main build script
```
### ✅ Node.js Dependencies Eliminated
-`package.json` - Removed
-`node_modules/` - Removed
-`npm run build` - Not needed
- ✅ Pure JavaScript OAuth implementation
- ✅ CDN-free, self-contained code
- ✅ Rust-only build process
---
## 📖 Original Features
[![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## 概要
ai.logは、[Anthropic Docs](https://docs.anthropic.com/)にインスパイアされたモダンなインターフェースを持つ、次世代静的ブログジェネレーターです。ai.gptとの深い統合、ローカルAI機能、atproto OAuth連携により、従来のブログシステムを超えた体験を提供します。
## 主な特徴
### 🎨 モダンインターフェース
- **Anthropic Docs風デザイン**: プロフェッショナルで読みやすい
- **Timeline形式**: BlueskyライクなタイムラインUI
- **自動TOC**: 右サイドバーに目次を自動生成
- **レスポンシブ**: モバイル・デスクトップ対応
### 🤖 Ask AI機能 ✅
- **ローカルAI**: Ollama(gemma2:2b)による質問応答
- **認証必須**: ATProto OAuth認証でアクセス制御
- **トップページ限定**: ブログコンテンツに特化した回答
- **CORS解決済み**: OLLAMA_ORIGINS設定でクロスオリジン問題解消
- **プロフィール連携**: AIアバターとしてATProtoプロフィール画像表示
- **レスポンス最適化**: 80文字制限+高いtemperatureで多様な回答
- **ローディング表示**: Font Awesomeアイコンによる一行ローディング
### 🔧 Ask AI設定方法
```bash
# 1. Ollama設定
brew install ollama
ollama pull gemma2:2b
# 2. CORS設定で起動
OLLAMA_ORIGINS="https://example.com" ollama serve
# 3. AI DID設定 (my-blog/templates/base.html)
const aiConfig = {
systemPrompt: 'You are a helpful AI assistant.',
aiDid: 'did:plc:your-ai-bot-did'
};
```
### 🌐 分散SNS連携
- **atproto OAuth**: Blueskyアカウントでログイン
- **コメントシステム**: 分散SNSコメント
- **データ主権**: ユーザーがデータを所有
### 🔗 エコシステム統合
- **ai.gpt**: ドキュメント同期・AI機能連携
- **MCP Server**: ai.gptからの操作をサポート
- **ai.wiki**: 自動ドキュメント同期
## Architecture
### Dual MCP Integration
**ai.log MCP Server (API Layer)**
- **Role**: Independent blog API
- **Port**: 8002
- **Location**: `./src/mcp/`
- **Function**: Core blog generation and management
**ai.gpt Integration (Server Layer)**
- **Role**: AI integration gateway
- **Port**: 8001 (within ai.gpt)
- **Location**: `../src/aigpt/mcp_server.py`
- **Function**: AI memory system + HTTP proxy to ai.log
### Data Flow
```
Claude Code → ai.gpt (Server/AI) → ai.log (API/Blog) → Static Site
↑ ↑
Memory System File Operations
Relationship AI Markdown Processing
Context Analysis Template Rendering
```
## Features
- Static blog generation (inspired by Zola)
- AI-powered article editing and enhancement
- Automatic translation (ja → en)
- AI comment system integrated with atproto
- OAuth authentication via atproto accounts
- **Static Blog Generation**: Inspired by Zola, built with Rust
- **AI-Powered Content**: Memory-driven article generation via ai.gpt
- **🌍 Ollama Translation**: Multi-language markdown translation with structure preservation
- **atproto Integration**: OAuth authentication and comment system (planned)
- **MCP Integration**: Seamless Claude Code workflow
## Installation
@ -22,6 +237,8 @@ cargo install ailog
## Usage
### Standalone Mode
```bash
# Initialize a new blog
ailog init myblog
@ -35,53 +252,432 @@ ailog build
# Serve locally
ailog serve
# Start MCP server
ailog mcp --port 8002
# Generate documentation
ailog doc readme --with-ai
ailog doc api --output ./docs
ailog doc structure --include-deps
# Translate documents (requires Ollama)
ailog doc translate --input README.md --target-lang en
ailog doc translate --input docs/api.md --target-lang ja --model qwen2.5:latest
# Clean build files
ailog clean
```
### AI Ecosystem Integration
When integrated with ai.gpt, use natural language:
- "ブログ記事を書いて" → Triggers `log_ai_content`
- "記事一覧を見せて" → Triggers `log_list_posts`
- "ブログをビルドして" → Triggers `log_build_blog`
### Documentation & Translation
Generate comprehensive documentation and translate content:
- "READMEを生成して" → Triggers `log_generate_docs`
- "APIドキュメントを作成して" → Generates API documentation
- "プロジェクト構造を解析して" → Creates structure documentation
- "このファイルを英語に翻訳して" → Triggers `log_translate_document`
- "マークダウンを日本語に変換して" → Uses Ollama for translation
## MCP Tools
### ai.log Server (Port 8002)
- `create_blog_post` - Create new blog post
- `list_blog_posts` - List existing posts
- `build_blog` - Build static site
- `get_post_content` - Get post by slug
- `translate_document` ⭐ - Ollama-powered markdown translation
- `generate_documentation` ⭐ - Code analysis and documentation generation
### ai.gpt Integration (Port 8001)
- `log_create_post` - Proxy to ai.log + error handling
- `log_list_posts` - Proxy to ai.log + formatting
- `log_build_blog` - Proxy to ai.log + AI features
- `log_get_post` - Proxy to ai.log + context
- `log_system_status` - Health check for ai.log
- `log_ai_content` ⭐ - AI memory → blog content generation
- `log_translate_document` 🌍 - Document translation via Ollama
- `log_generate_docs` 📚 - Documentation generation
### Documentation Generation Tools
- `doc readme` - Generate README.md from project analysis
- `doc api` - Generate API documentation
- `doc structure` - Analyze and document project structure
- `doc changelog` - Generate changelog from git history
- `doc translate` 🌍 - Multi-language document translation
### Translation Features
- **Language Support**: English, Japanese, Chinese, Korean, Spanish
- **Markdown Preservation**: Code blocks, links, images, tables maintained
- **Auto-Detection**: Automatically detects Japanese content
- **Ollama Integration**: Uses local AI models for privacy and cost-efficiency
- **Smart Processing**: Section-by-section translation with structure awareness
## Configuration
Configuration files are stored in `~/.config/syui/ai/log/`
### ai.log Configuration
- Location: `~/.config/syui/ai/log/`
- Format: TOML configuration
## AI Integration (Planned)
### ai.gpt Integration
- Configuration: `../config.json`
- Auto-detection: ai.log tools enabled when `./log/` directory exists
- System prompt: Automatically triggers blog tools for related queries
- Automatic content suggestions and corrections
- Multi-language support with AI translation
- AI-generated comments linked to atproto accounts
## AI Integration Features
### Memory-Driven Content Generation
- **Source**: ai.gpt memory system
- **Process**: Contextual memories → AI analysis → Blog content
- **Output**: Structured markdown with personal insights
### Automatic Workflows
- Daily blog posts from accumulated memories
- Content enhancement and suggestions
- Related article recommendations
- Multi-language content generation
## atproto Integration (Planned)
Implements OAuth 2.0 for user authentication:
- Users can comment using their atproto accounts
- Comments are stored in atproto collections
- Full data sovereignty for users
### OAuth 2.0 Authentication
- Client metadata: `public/client-metadata.json`
- Comment system integration
- Data sovereignty: Users own their comments
- Collection storage in atproto
### Comment System
- **ATProto Stream Monitoring**: Real-time Jetstream connection monitoring
- **Collection Tracking**: Monitors `ai.syui.log` collection for new comments
- **User Management**: Automatically adds commenting users to `ai.syui.log.user` collection
- **Comment Display**: Fetches and displays comments from registered users
- **OAuth Integration**: atproto account login via Cloudflare tunnel
- **Distributed Storage**: Comments stored in user-owned atproto collections
## Build & Deploy
Designed for GitHub Actions and Cloudflare Pages deployment. Push to main branch triggers automatic build and deploy.
### GitHub Actions
```yaml
# .github/workflows/gh-pages.yml
- name: Build ai.log
run: |
cd log
cargo build --release
./target/release/ailog build
```
### Cloudflare Pages
- Static output: `./public/`
- Automatic deployment on main branch push
- AI content generation during build process
## Development Status
Currently implemented:
- Project structure and Cargo.toml setup
- ✅ Basic command-line interface (init, new, build, serve, clean)
- Configuration system with TOML support
- Markdown parsing with frontmatter support
- Template system with Handlebars
- Static site generation with posts and pages
- Development server with hot reload
- ✅ AI integration foundation (GPT client, translator, comment system)
- ✅ atproto client with OAuth support
- ✅ MCP server integration for AI tools
- ✅ Test blog with sample content and styling
### ✅ Completed Features
- Project structure and Cargo.toml setup
- CLI interface (init, new, build, serve, clean, mcp, doc)
- Configuration system with TOML support
- Markdown parsing with frontmatter support
- Template system with Handlebars
- Static site generation with posts and pages
- Development server with hot reload
- **MCP server integration (both layers)**
- **ai.gpt integration with 6 tools**
- **AI memory system connection**
- **📚 Documentation generation from code**
- **🔍 Rust project analysis and API extraction**
- **📝 README, API docs, and structure analysis**
- **🌍 Ollama-powered translation system**
- **🚀 Complete MCP integration with ai.gpt**
- **📄 Markdown-aware translation preserving structure**
- **💬 ATProto comment system with Jetstream monitoring**
- **🔄 Real-time comment collection and user management**
- **🔐 OAuth 2.1 integration with Cloudflare tunnel**
- **🤖 Ask AI feature with Ollama integration**
- **⚡ CORS resolution via OLLAMA_ORIGINS**
- **🔒 Authentication-gated AI chat**
- **📱 Top-page-only AI access pattern**
- Test blog with sample content and styling
Planned features:
- AI-powered content enhancement and suggestions
- Automatic translation (ja → en) pipeline
- atproto comment system with OAuth authentication
### 🚧 In Progress
- AI-powered content enhancement pipeline
- Advanced comment moderation system
### 📋 Planned Features
- Advanced template customization
- Plugin system for extensibility
- Real-time comment system
- Multi-blog management
- VTuber integration (ai.verse connection)
## Integration with ai Ecosystem
### System Dependencies
- **ai.gpt**: Memory system, relationship tracking, AI provider
- **ai.card**: Future cross-system content sharing
- **ai.bot**: atproto posting and mention handling
- **ai.verse**: 3D world blog representation (future)
### yui System Compliance
- **Uniqueness**: Each blog post tied to individual identity
- **Reality Reflection**: Personal memories → digital content
- **Irreversibility**: Published content maintains historical integrity
## Getting Started
### 1. Standalone Usage
```bash
git clone [repository]
cd log
cargo run -- init my-blog
cargo run -- new "First Post"
cargo run -- build
cargo run -- serve
```
### 2. AI Ecosystem Integration
```bash
# Start ai.log MCP server
cargo run -- mcp --port 8002
# In another terminal, start ai.gpt
cd ../
# ai.gpt startup commands
# Use Claude Code with natural language blog commands
```
## Documentation Generation Features
### 📚 Automatic README Generation
```bash
# Generate README from project analysis
ailog doc readme --source ./src --with-ai
# Output: Enhanced README.md with:
# - Project overview and metrics
# - Dependency analysis
# - Module structure
# - AI-generated insights
```
### 📖 API Documentation
```bash
# Generate comprehensive API docs
ailog doc api --source ./src --format markdown --output ./docs
# Creates:
# - docs/api.md (main API overview)
# - docs/module_name.md (per-module documentation)
# - Function signatures and documentation
# - Struct/enum definitions
```
### 🏗️ Project Structure Analysis
```bash
# Analyze and document project structure
ailog doc structure --source . --include-deps
# Generates:
# - Directory tree visualization
# - File distribution by language
# - Dependency graph analysis
# - Code metrics and statistics
```
### 📝 Git Changelog Generation
```bash
# Generate changelog from git history
ailog doc changelog --from v1.0.0 --explain-changes
# Creates:
# - Structured changelog
# - Commit categorization
# - AI-enhanced change explanations
```
### 🤖 AI-Enhanced Documentation
When `--with-ai` is enabled:
- **Content Enhancement**: AI improves readability and adds insights
- **Context Awareness**: Leverages ai.gpt memory system
- **Smart Categorization**: Automatic organization of content
- **Technical Writing**: Professional documentation style
## 🌍 Translation System
### Ollama-Powered Translation
ai.log includes a comprehensive translation system powered by Ollama AI models:
```bash
# Basic translation
ailog doc translate --input README.md --target-lang en
# Advanced translation with custom settings
ailog doc translate \
--input docs/technical-guide.ja.md \
--target-lang en \
--source-lang ja \
--output docs/technical-guide.en.md \
--model qwen2.5:latest \
--ollama-endpoint http://localhost:11434
```
### Translation Features
#### 📄 Markdown-Aware Processing
- **Code Block Preservation**: All code snippets remain untranslated
- **Link Maintenance**: URLs and link structures preserved
- **Image Handling**: Alt text can be translated while preserving image paths
- **Table Translation**: Table content translated while maintaining structure
- **Header Preservation**: Markdown headers translated with level maintenance
#### 🎯 Smart Language Detection
- **Auto-Detection**: Automatically detects Japanese content using Unicode ranges
- **Manual Override**: Specify source language for precise control
- **Mixed Content**: Handles documents with multiple languages
#### 🔧 Flexible Configuration
- **Model Selection**: Choose from available Ollama models
- **Custom Endpoints**: Use different Ollama instances
- **Output Control**: Auto-generate or specify output paths
- **Batch Processing**: Process multiple files efficiently
### Supported Languages
| Language | Code | Direction | Model Optimized |
|----------|------|-----------|-----------------|
| English | `en` | ↔️ | ✅ qwen2.5 |
| Japanese | `ja` | ↔️ | ✅ qwen2.5 |
| Chinese | `zh` | ↔️ | ✅ qwen2.5 |
| Korean | `ko` | ↔️ | ⚠️ Basic |
| Spanish | `es` | ↔️ | ⚠️ Basic |
### Translation Workflow
1. **Parse Document**: Analyze markdown structure and identify sections
2. **Preserve Code**: Isolate code blocks and technical content
3. **Translate Content**: Process text sections with Ollama AI
4. **Reconstruct**: Rebuild document maintaining original formatting
5. **Validate**: Ensure structural integrity and completeness
### Integration with ai.gpt
```python
# Via ai.gpt MCP tools
await log_translate_document(
input_file="README.ja.md",
target_lang="en",
model="qwen2.5:latest"
)
```
### Requirements
- **Ollama**: Install and run Ollama locally
- **Models**: Download supported models (qwen2.5:latest recommended)
- **Memory**: Sufficient RAM for model inference
- **Network**: For initial model download only
## Configuration Examples
### Basic Blog Config
```toml
[blog]
title = "My AI Blog"
description = "Personal thoughts and AI insights"
base_url = "https://myblog.example.com"
[ai]
provider = "openai"
model = "gpt-4"
translation = true
```
### Advanced Integration
```json
// ../config.json (ai.gpt)
{
"mcp": {
"servers": {
"ai_gpt": {
"endpoints": {
"log_ai_content": "/log_ai_content",
"log_create_post": "/log_create_post"
}
}
}
}
}
```
## Troubleshooting
### MCP Connection Issues
- Ensure ai.log server is running: `cargo run -- mcp --port 8002`
- Check ai.gpt config includes log endpoints
- Verify `./log/` directory exists relative to ai.gpt
### Build Failures
- Check Rust version: `rustc --version`
- Update dependencies: `cargo update`
- Clear cache: `cargo clean`
### AI Integration Problems
- Verify ai.gpt memory system is initialized
- Check AI provider configuration
- Ensure sufficient context in memory system
## systemd
```sh
$ sudo vim /usr/lib/systemd/system/ollama.service
[Service]
Environment="OLLAMA_ORIGINS=https://example.com"
```
```sh
# ファイルをsystemdディレクトリにコピー
sudo cp ./systemd/system/ailog-stream.service /etc/systemd/system/
sudo cp ./systemd/system/cloudflared-log.service /etc/systemd/system/
# 権限設定
sudo chmod 644 /etc/systemd/system/ailog-stream.service
sudo chmod 644 /etc/systemd/system/cloudflared-log.service
# systemd設定reload
sudo systemctl daemon-reload
# サービス有効化・開始
sudo systemctl enable ailog-stream.service
sudo systemctl enable cloudflared-log.service
sudo systemctl start ailog-stream.service
sudo systemctl start cloudflared-log.service
# 状態確認
sudo systemctl status ailog-stream.service
sudo systemctl status cloudflared-log.service
# ログ確認
journalctl -u ailog-stream.service -f
journalctl -u cloudflared-log.service -f
設定のポイント:
- User=syui でユーザー権限で実行
- Restart=always で異常終了時自動再起動
- After=network.target でネットワーク起動後に実行
- StandardOutput=journal でログをjournalctlで確認可能
```
## License
© syui
---
**Part of the ai ecosystem**: ai.gpt, ai.card, ai.log, ai.bot, ai.verse, ai.shell

148
action.yml Normal file
View File

@ -0,0 +1,148 @@
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: Check and update ailog binary
shell: bash
run: |
# Get latest release version (for Gitea, adjust API endpoint if needed)
if command -v curl >/dev/null 2>&1; then
LATEST_VERSION=$(curl -s https://api.github.com/repos/syui/ailog/releases/latest | jq -r .tag_name 2>/dev/null || echo "v0.1.1")
else
LATEST_VERSION="v0.1.1" # fallback version
fi
echo "Target version: $LATEST_VERSION"
# Check current binary version if exists
mkdir -p ./bin
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version | awk '{print $2}' 2>/dev/null || echo "unknown")
echo "Current version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Download if version is different or binary doesn't exist
if [ "$CURRENT_VERSION" != "${LATEST_VERSION#v}" ]; then
echo "Downloading ailog $LATEST_VERSION..."
# Try GitHub first, then fallback to local build
if curl -sL https://github.com/syui/ailog/releases/download/$LATEST_VERSION/ailog-linux-x86_64.tar.gz | tar -xzf - 2>/dev/null; then
mv ailog ./bin/ailog
chmod +x ./bin/ailog
echo "Downloaded binary: $(./bin/ailog --version)"
else
echo "Download failed, building from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
cp ./target/release/ailog ./bin/ailog
echo "Built from source: $(./bin/ailog --version)"
else
echo "Error: Neither download nor cargo build available"
exit 1
fi
fi
else
echo "Binary is up to date"
chmod +x ./bin/ailog
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)
./bin/ailog build \
--content ${{ inputs.content-dir }} \
--output ${{ inputs.output-dir }} \
--templates ${{ inputs.template-dir }} \
--static ${{ inputs.static-dir }} \
--config ${{ inputs.config-file }}
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

18
cloudflared-config.yml Normal file
View File

@ -0,0 +1,18 @@
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

View File

@ -1,142 +0,0 @@
# ai.log MCP Integration Guide
ai.logをai.gptと連携するためのMCPサーバー設定ガイド
## MCPサーバー起動
```bash
# ai.logプロジェクトディレクトリで
./target/debug/ailog mcp --port 8002
# またはサブディレクトリから
./target/debug/ailog mcp --port 8002 --path /path/to/blog
```
## ai.gptでの設定
ai.gptの設定ファイル `~/.config/syui/ai/gpt/config.json` に以下を追加:
```json
{
"mcp": {
"enabled": true,
"servers": {
"ai_gpt": {"base_url": "http://localhost:8001"},
"ai_card": {"base_url": "http://localhost:8000"},
"ai_log": {"base_url": "http://localhost:8002"}
}
}
}
```
## 利用可能なMCPツール
### 1. create_blog_post
新しいブログ記事を作成します。
**パラメータ**:
- `title` (必須): 記事のタイトル
- `content` (必須): Markdown形式の記事内容
- `tags` (オプション): 記事のタグ配列
- `slug` (オプション): カスタムURL slug
**使用例**:
```python
# ai.gptからの呼び出し例
result = await mcp_client.call_tool("create_blog_post", {
"title": "AI統合の新しい可能性",
"content": "# 概要\n\nai.gptとai.logの連携により...",
"tags": ["AI", "技術", "ブログ"]
})
```
### 2. list_blog_posts
既存のブログ記事一覧を取得します。
**パラメータ**:
- `limit` (オプション): 取得件数上限 (デフォルト: 10)
- `offset` (オプション): スキップ件数 (デフォルト: 0)
### 3. build_blog
ブログをビルドして静的ファイルを生成します。
**パラメータ**:
- `enable_ai` (オプション): AI機能を有効化
- `translate` (オプション): 自動翻訳を有効化
### 4. get_post_content
指定したスラッグの記事内容を取得します。
**パラメータ**:
- `slug` (必須): 記事のスラッグ
## ai.gptからの連携パターン
### 記事の自動投稿
```python
# 記憶システムから関連情報を取得
memories = get_contextual_memories("ブログ")
# AI記事生成
content = generate_blog_content(memories)
# ai.logに投稿
result = await mcp_client.call_tool("create_blog_post", {
"title": "今日の思考メモ",
"content": content,
"tags": ["日記", "AI"]
})
# ビルド実行
await mcp_client.call_tool("build_blog", {"enable_ai": True})
```
### 記事一覧の確認と編集
```python
# 記事一覧取得
posts = await mcp_client.call_tool("list_blog_posts", {"limit": 5})
# 特定記事の内容取得
content = await mcp_client.call_tool("get_post_content", {
"slug": "ai-integration"
})
# 修正版を投稿(上書き)
updated_content = enhance_content(content)
await mcp_client.call_tool("create_blog_post", {
"title": "AI統合の新しい可能性改訂版",
"content": updated_content,
"slug": "ai-integration-revised"
})
```
## 自動化ワークフロー
ai.gptのスケジューラーと組み合わせて
1. **日次ブログ投稿**: 蓄積された記憶から記事を自動生成・投稿
2. **記事修正**: 既存記事の内容を自動改善
3. **関連記事提案**: 過去記事との関連性に基づく新記事提案
4. **多言語対応**: 自動翻訳によるグローバル展開
## エラーハンドリング
MCPツール呼び出し時のエラーは以下の形式で返されます
```json
{
"jsonrpc": "2.0",
"id": "request_id",
"error": {
"code": -32000,
"message": "エラーメッセージ",
"data": null
}
}
```
## セキュリティ考慮事項
- MCPサーバーはローカルホストでのみ動作
- ai.gptからの認証済みリクエストのみ処理
- ファイルアクセスは指定されたブログディレクトリ内に制限

31
my-blog/config.toml Normal file
View File

@ -0,0 +1,31 @@
[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:2b"
host = "https://ollama.syui.ai"
system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
collection_comment = "ai.syui.log"
collection_user = "ai.syui.log.user"
collection_chat = "ai.syui.log.chat"
bsky_api = "https://public.api.bsky.app"

View File

@ -0,0 +1,159 @@
---
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 server 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
```
## 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!");
```

View File

@ -0,0 +1,62 @@
---
title: "ブログを移行した"
slug: "blog"
date: 2025-06-14
tags: ["blog", "cloudflare", "github"]
draft: false
---
ブログを移行しました。
1. `gh-pages`から`cf-pages`への移行になります。
2. `hugo`からの移行で、自作の`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'
```

View File

@ -0,0 +1,14 @@
{
"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"
}
]
}

51
my-blog/static/_headers Normal file
View File

@ -0,0 +1,51 @@
/*
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: public, max-age=60
/*.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

11
my-blog/static/_redirects Normal file
View File

@ -0,0 +1,11 @@
# AI機能をai.gpt MCP serverにリダイレクト
/api/ask https://ai-gpt-mcp.syui.ai/ask 200
# Ollama API proxy (Cloudflare Workers)
/api/ollama-proxy https://ollama-proxy.YOUR-SUBDOMAIN.workers.dev/:splat 200
# OAuth routes
/oauth/* /oauth/index.html 200
# SPA routing support
/* /index.html 200

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,24 @@
{
"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
}

View File

@ -0,0 +1,894 @@
/* 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 auto 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;
}
.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;
width: 100%;
}
@media (max-width: 1000px) {
.main-content {
padding: 20px;
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-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;
}
.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);
position: relative;
}
/* File name display for code blocks */
.article-body pre[data-filename]::before {
content: attr(data-filename);
position: absolute;
top: 0;
right: 0;
background: #2D2D30;
color: #CCCCCC;
padding: 4px 12px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
border-bottom-left-radius: 4px;
border: 1px solid #3E3D32;
border-top: none;
border-right: none;
z-index: 1;
}
.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;
}
/* 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: 12px 0;
}
.header-content {
max-width: 100%;
padding: 0 20px;
grid-template-columns: auto 1fr auto;
gap: 0;
}
/* 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 */
.ask-ai-btn {
padding: 8px;
min-width: 40px;
justify-content: center;
font-size: 0;
gap: 0;
}
.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;
}
.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;
}
.message-header .avatar {
width: 32px;
height: 32px;
font-size: 16px;
}
/* Center content on mobile */
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
padding: 0;
}
}

View File

@ -0,0 +1,342 @@
/* 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); }
}

BIN
my-blog/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
my-blog/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,22 @@
<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>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-Do1JWJCw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-CPKYAM8U.css">

360
my-blog/static/js/ask-ai.js Normal file
View File

@ -0,0 +1,360 @@
/**
* Ask AI functionality - Pure JavaScript, no jQuery dependency
*/
class AskAI {
constructor() {
this.isReady = false;
this.aiProfile = null;
this.init();
}
init() {
this.setupEventListeners();
this.checkAuthOnLoad();
}
setupEventListeners() {
// Listen for AI ready signal
window.addEventListener('aiChatReady', () => {
this.isReady = true;
console.log('AI Chat is ready');
});
// Listen for AI profile updates
window.addEventListener('aiProfileLoaded', (event) => {
this.aiProfile = event.detail;
console.log('AI profile loaded:', this.aiProfile);
this.updateButton();
});
// Listen for AI responses
window.addEventListener('aiResponseReceived', (event) => {
this.handleAIResponse(event.detail);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hide();
}
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
e.preventDefault();
this.ask();
}
});
// Monitor authentication changes
this.observeAuth();
}
toggle() {
const panel = document.getElementById('askAiPanel');
const isVisible = panel.style.display !== 'none';
if (isVisible) {
this.hide();
} else {
this.show();
}
}
show() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'block';
this.checkAuth();
}
hide() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'none';
}
checkAuth() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
const authCheck = document.getElementById('authCheck');
const chatForm = document.getElementById('chatForm');
const chatHistory = document.getElementById('chatHistory');
if (isAuthenticated) {
authCheck.style.display = 'none';
chatForm.style.display = 'block';
chatHistory.style.display = 'block';
if (chatHistory.children.length === 0) {
this.showGreeting();
}
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
authCheck.style.display = 'block';
chatForm.style.display = 'none';
chatHistory.style.display = 'none';
}
}
checkAuthOnLoad() {
setTimeout(() => {
this.checkAuth();
}, 500);
}
observeAuth() {
const observer = new MutationObserver(() => {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length > 0) {
this.checkAuth();
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
updateButton() {
const button = document.getElementById('askAiButton');
if (this.aiProfile && this.aiProfile.displayName) {
const textNode = button.childNodes[2];
if (textNode) {
textNode.textContent = this.aiProfile.displayName;
}
}
}
showGreeting() {
if (!this.aiProfile) return;
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = this.aiProfile.avatar
? `<img src="${this.aiProfile.avatar}" alt="${this.aiProfile.displayName}" class="profile-avatar">`
: '🤖';
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${this.aiProfile.displayName}</div>
<div class="handle">@${this.aiProfile.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);
}
async ask() {
const question = document.getElementById('aiQuestion').value;
const chatHistory = document.getElementById('chatHistory');
const askButton = document.getElementById('askButton');
if (!question.trim()) return;
// Wait for AI to be ready
if (!this.isReady) {
await this.waitForReady();
}
// Disable button
askButton.disabled = true;
askButton.textContent = 'Posting...';
try {
// Add user message
this.addUserMessage(question);
// Clear input
document.getElementById('aiQuestion').value = '';
// Show loading
this.showLoading();
// Post question
const event = new CustomEvent('postAIQuestion', {
detail: { question: question }
});
window.dispatchEvent(event);
} catch (error) {
this.showError('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
}
waitForReady() {
return new Promise(resolve => {
const checkReady = setInterval(() => {
if (this.isReady) {
clearInterval(checkReady);
resolve();
}
}, 100);
});
}
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);
}
showLoading() {
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);
}
showError(message) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
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);
}
removeLoading() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
handleAIResponse(responseData) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
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
this.limitChatHistory();
}
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]);
}
}
}
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
try {
window.askAIInstance = new AskAI();
console.log('Ask AI initialized successfully');
} catch (error) {
console.error('Failed to initialize Ask AI:', error);
}
});
// Global function for onclick
window.AskAI = {
toggle: function() {
console.log('AskAI.toggle called');
if (window.askAIInstance) {
window.askAIInstance.toggle();
} else {
console.error('Ask AI instance not available');
}
},
ask: function() {
console.log('AskAI.ask called');
if (window.askAIInstance) {
window.askAIInstance.ask();
} else {
console.error('Ask AI instance not available');
}
}
};

View File

@ -0,0 +1,94 @@
/**
* 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();
});

View File

@ -0,0 +1,165 @@
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

View File

@ -0,0 +1,6 @@
/*!
* 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}

View File

@ -0,0 +1,6 @@
/*!
* 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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,99 @@
@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";
}

24
my-blog/static/syui.svg Normal file
View File

@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1,96 @@
<!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">
{% include "oauth-assets.html" %}
{% 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="AskAI.toggle()" 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="AskAI.ask()" 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>
</body>
</html>

View File

@ -0,0 +1,52 @@
{% 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 %}

View File

@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-Do1JWJCw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-CPKYAM8U.css">

View File

@ -0,0 +1,71 @@
<!-- 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>

View File

@ -0,0 +1,373 @@
{% 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 %}

View File

@ -0,0 +1,196 @@
{% 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 %}

View File

@ -0,0 +1,93 @@
{% 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 %}

27
oauth/.env.production Normal file
View File

@ -0,0 +1,27 @@
# 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
# 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
AILOG_COLLECTION_CHAT=ai.syui.log.chat
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:2b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app

20
oauth/index.html Normal file
View File

@ -0,0 +1,20 @@
<!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>

31
oauth/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"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"
}
}

View File

@ -0,0 +1,14 @@
{
"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"
}
]
}

View File

@ -0,0 +1,24 @@
{
"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
}

881
oauth/src/App.css Normal file
View File

@ -0,0 +1,881 @@
/* 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;
}
}
.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;
}

1266
oauth/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
// 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,
}
}),
});

View File

@ -0,0 +1,279 @@
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;
};

View File

@ -0,0 +1,79 @@
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>
);
};

View File

@ -0,0 +1,120 @@
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>
);
};

View File

@ -0,0 +1,171 @@
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>
);
};

View File

@ -0,0 +1,113 @@
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>
);
};

View File

@ -0,0 +1,133 @@
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>
);
};

View File

@ -0,0 +1,130 @@
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>
);
};

View File

@ -0,0 +1,144 @@
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>
);
};

View File

@ -0,0 +1,203 @@
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>
);
};

View File

@ -0,0 +1,253 @@
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);

View File

@ -0,0 +1,42 @@
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>
);
};

110
oauth/src/config/app.ts Normal file
View File

@ -0,0 +1,110 @@
// Application configuration
export interface AppConfig {
adminDid: string;
collections: {
comment: string;
user: string;
chat: string;
};
host: string;
rkey?: string; // Current post rkey if on post page
aiEnabled: boolean;
aiAskAi: boolean;
aiProvider: string;
aiModel: string;
aiHost: string;
bskyPublicApi: string;
}
// Generate collection names from host
// Format: ${reg}.${name}.${sub}
// Example: log.syui.ai -> ai.syui.log
function generateCollectionNames(host: string): { comment: string; user: string; chat: string } {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
// Split host into parts
const parts = cleanHost.split('.');
if (parts.length < 2) {
throw new Error('Invalid host format');
}
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const collectionBase = reversedParts.join('.');
return {
comment: collectionBase,
user: `${collectionBase}.user`,
chat: `${collectionBase}.chat`
};
} catch (error) {
console.warn('Failed to generate collection names from host:', host, error);
// Fallback to default collections
return {
comment: 'ai.syui.log',
user: 'ai.syui.log.user',
chat: 'ai.syui.log.chat'
};
}
}
// Extract rkey from current URL
// /posts/xxx.html -> xxx
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\.html$/);
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 autoGeneratedCollections = generateCollectionNames(host);
const collections = {
comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment,
user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user,
chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat,
};
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();

30
oauth/src/main.tsx Normal file
View File

@ -0,0 +1,30 @@
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>,
);
});

107
oauth/src/services/api.ts Normal file
View File

@ -0,0 +1,107 @@
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;
}
},
};

View File

@ -0,0 +1,686 @@
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 };

109
oauth/src/services/auth.ts Normal file
View File

@ -0,0 +1,109 @@
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 };

331
oauth/src/styles/Card.css Normal file
View File

@ -0,0 +1,331 @@
.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;
}

View File

@ -0,0 +1,196 @@
.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;
}

View File

@ -0,0 +1,170 @@
.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;
}
}

View File

@ -0,0 +1,172 @@
.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;
}

View File

@ -0,0 +1,174 @@
.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; }
}

View File

@ -0,0 +1,219 @@
.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;
}

243
oauth/src/styles/Login.css Normal file
View File

@ -0,0 +1,243 @@
.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;
}

24
oauth/src/types/card.ts Normal file
View File

@ -0,0 +1,24 @@
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;
}

View File

@ -0,0 +1,141 @@
/**
* 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);
}
}

View File

@ -0,0 +1,183 @@
/**
* 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`
};
}

21
oauth/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"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" }]
}

11
oauth/tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

88
oauth/vite.config.ts Normal file
View File

@ -0,0 +1,88 @@
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' }
]
}
}
}
})

71
run.zsh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/zsh
function _env() {
d=${0:a:h}
ailog=$d/target/release/ailog
oauth=$d/oauth
myblog=$d/my-blog
port=4173
source $oauth/.env.production
case $OSTYPE in
darwin*)
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
;;
esac
}
function _server() {
lsof -ti:$port | xargs kill -9 2>/dev/null || true
cd $d/my-blog
cargo build --release
$ailog build
$ailog serve --port $port
}
function _server_public() {
cloudflared tunnel --config $d/cloudflared-config.yml run
}
function _oauth_build() {
cd $oauth
nvm use 21
npm i
npm run build
rm -rf $myblog/static/assets
cp -rf dist/* $myblog/static/
cp $oauth/dist/index.html $myblog/templates/oauth-assets.html
#npm run preview
}
function _server_comment() {
cargo build --release
AILOG_DEBUG_ALL=1 $ailog stream start my-blog
}
function _server_ollama(){
lsof -ti:11434 | xargs kill -9 2>/dev/null || true
brew services stop ollama
OLLAMA_ORIGINS="https://log.syui.ai" ollama serve
}
_env
case "${1:-serve}" in
tunnel|c)
_server_public
;;
oauth|o)
_oauth_build
;;
comment|co)
_server_comment
;;
ollama|ol)
_server_ollama
;;
serve|s|*)
_server
;;
esac

View File

@ -28,6 +28,7 @@ Only return the enhanced content without explanations.";
self.client.chat(system_prompt, &user_prompt).await
}
#[allow(dead_code)]
pub async fn suggest_improvements(&self, content: &str) -> Result<Vec<String>> {
let system_prompt = "You are a content analyzer. Analyze the given content and provide:
1. Suggestions for improving the content

View File

@ -21,6 +21,7 @@ impl<'a> Translator<'a> {
self.client.chat(&system_prompt, content).await
}
#[allow(dead_code)]
pub async fn translate_post(&self, title: &str, content: &str, from: &str, to: &str) -> Result<(String, String)> {
// Translate title
let translated_title = self.translate(title, from, to).await?;

313
src/analyzer/mod.rs Normal file
View File

@ -0,0 +1,313 @@
pub mod rust_analyzer;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfo {
pub name: String,
pub description: Option<String>,
pub version: String,
pub authors: Vec<String>,
pub license: Option<String>,
pub dependencies: HashMap<String, String>,
pub modules: Vec<ModuleInfo>,
pub structure: ProjectStructure,
pub metrics: ProjectMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleInfo {
pub name: String,
pub path: PathBuf,
pub functions: Vec<FunctionInfo>,
pub structs: Vec<StructInfo>,
pub enums: Vec<EnumInfo>,
pub traits: Vec<TraitInfo>,
pub docs: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionInfo {
pub name: String,
pub visibility: String,
pub is_async: bool,
pub parameters: Vec<Parameter>,
pub return_type: Option<String>,
pub docs: Option<String>,
pub line_number: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
pub param_type: String,
pub is_mutable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructInfo {
pub name: String,
pub visibility: String,
pub fields: Vec<FieldInfo>,
pub docs: Option<String>,
pub line_number: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldInfo {
pub name: String,
pub field_type: String,
pub visibility: String,
pub docs: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnumInfo {
pub name: String,
pub visibility: String,
pub variants: Vec<VariantInfo>,
pub docs: Option<String>,
pub line_number: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariantInfo {
pub name: String,
pub fields: Vec<FieldInfo>,
pub docs: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraitInfo {
pub name: String,
pub visibility: String,
pub methods: Vec<FunctionInfo>,
pub docs: Option<String>,
pub line_number: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectStructure {
pub directories: Vec<DirectoryInfo>,
pub files: Vec<FileInfo>,
pub dependency_graph: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryInfo {
pub name: String,
pub path: PathBuf,
pub file_count: usize,
pub subdirectories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub name: String,
pub path: PathBuf,
pub language: String,
pub lines_of_code: usize,
pub is_test: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetrics {
pub total_lines: usize,
pub total_files: usize,
pub test_files: usize,
pub dependency_count: usize,
pub complexity_score: f32,
pub test_coverage: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiInfo {
pub modules: Vec<ModuleInfo>,
pub public_functions: Vec<FunctionInfo>,
pub public_structs: Vec<StructInfo>,
pub public_enums: Vec<EnumInfo>,
pub public_traits: Vec<TraitInfo>,
}
pub struct CodeAnalyzer {
rust_analyzer: rust_analyzer::RustAnalyzer,
}
impl CodeAnalyzer {
pub fn new() -> Self {
Self {
rust_analyzer: rust_analyzer::RustAnalyzer::new(),
}
}
pub fn analyze_project(&self, path: &Path) -> Result<ProjectInfo> {
println!(" 🔍 Analyzing project at: {}", path.display());
// Check if this is a Rust project
let cargo_toml = path.join("Cargo.toml");
if cargo_toml.exists() {
return self.rust_analyzer.analyze_project(path);
}
// For now, only support Rust projects
anyhow::bail!("Only Rust projects are currently supported");
}
pub fn analyze_api(&self, path: &Path) -> Result<ApiInfo> {
println!(" 📚 Analyzing API at: {}", path.display());
let project_info = self.analyze_project(path.parent().unwrap_or(path))?;
// Extract only public items
let mut public_functions = Vec::new();
let mut public_structs = Vec::new();
let mut public_enums = Vec::new();
let mut public_traits = Vec::new();
for module in &project_info.modules {
for func in &module.functions {
if func.visibility == "pub" {
public_functions.push(func.clone());
}
}
for struct_info in &module.structs {
if struct_info.visibility == "pub" {
public_structs.push(struct_info.clone());
}
}
for enum_info in &module.enums {
if enum_info.visibility == "pub" {
public_enums.push(enum_info.clone());
}
}
for trait_info in &module.traits {
if trait_info.visibility == "pub" {
public_traits.push(trait_info.clone());
}
}
}
Ok(ApiInfo {
modules: project_info.modules,
public_functions,
public_structs,
public_enums,
public_traits,
})
}
pub fn analyze_structure(&self, path: &Path, include_deps: bool) -> Result<ProjectStructure> {
println!(" 🏗️ Analyzing structure at: {}", path.display());
let mut directories = Vec::new();
let mut files = Vec::new();
let mut dependency_graph = HashMap::new();
self.walk_directory(path, &mut directories, &mut files)?;
if include_deps {
dependency_graph = self.analyze_dependencies(path)?;
}
Ok(ProjectStructure {
directories,
files,
dependency_graph,
})
}
fn walk_directory(
&self,
path: &Path,
directories: &mut Vec<DirectoryInfo>,
files: &mut Vec<FileInfo>,
) -> Result<()> {
use walkdir::WalkDir;
let walker = WalkDir::new(path)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
// Skip hidden files and common build/cache directories
!name.starts_with('.')
&& name != "target"
&& name != "node_modules"
&& name != "dist"
});
for entry in walker {
let entry = entry?;
let path = entry.path();
let relative_path = path.strip_prefix(path.ancestors().last().unwrap())?;
if entry.file_type().is_dir() {
let file_count = std::fs::read_dir(path)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.count();
let subdirectories = std::fs::read_dir(path)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
directories.push(DirectoryInfo {
name: path.file_name().unwrap().to_string_lossy().to_string(),
path: relative_path.to_path_buf(),
file_count,
subdirectories,
});
} else if entry.file_type().is_file() {
let language = self.detect_language(path);
let lines_of_code = self.count_lines(path)?;
let is_test = self.is_test_file(path);
files.push(FileInfo {
name: path.file_name().unwrap().to_string_lossy().to_string(),
path: relative_path.to_path_buf(),
language,
lines_of_code,
is_test,
});
}
}
Ok(())
}
fn detect_language(&self, path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some("rs") => "rust".to_string(),
Some("py") => "python".to_string(),
Some("js") => "javascript".to_string(),
Some("ts") => "typescript".to_string(),
Some("md") => "markdown".to_string(),
Some("toml") => "toml".to_string(),
Some("json") => "json".to_string(),
Some("yaml") | Some("yml") => "yaml".to_string(),
_ => "unknown".to_string(),
}
}
fn count_lines(&self, path: &Path) -> Result<usize> {
let content = std::fs::read_to_string(path)?;
Ok(content.lines().count())
}
fn is_test_file(&self, path: &Path) -> bool {
let filename = path.file_name().unwrap().to_string_lossy();
filename.contains("test")
|| filename.starts_with("test_")
|| path.to_string_lossy().contains("/tests/")
}
fn analyze_dependencies(&self, _path: &Path) -> Result<HashMap<String, Vec<String>>> {
// For now, just return empty dependencies
// TODO: Implement actual dependency analysis
Ok(HashMap::new())
}
}

View File

@ -0,0 +1,512 @@
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use syn::{visit::Visit, ItemEnum, ItemFn, ItemStruct, ItemTrait, Visibility};
use super::*;
pub struct RustAnalyzer;
impl RustAnalyzer {
pub fn new() -> Self {
Self
}
pub fn analyze_project(&self, path: &Path) -> Result<ProjectInfo> {
// Parse Cargo.toml
let cargo_toml_path = path.join("Cargo.toml");
let cargo_content = std::fs::read_to_string(&cargo_toml_path)?;
let cargo_toml: toml::Value = toml::from_str(&cargo_content)?;
let package = cargo_toml.get("package").unwrap();
let name = package.get("name").unwrap().as_str().unwrap().to_string();
let description = package.get("description").map(|v| v.as_str().unwrap().to_string());
let version = package.get("version").unwrap().as_str().unwrap().to_string();
let authors = package
.get("authors")
.map(|v| {
v.as_array()
.unwrap()
.iter()
.map(|a| a.as_str().unwrap().to_string())
.collect()
})
.unwrap_or_default();
let license = package.get("license").map(|v| v.as_str().unwrap().to_string());
// Parse dependencies
let dependencies = self.parse_dependencies(&cargo_toml)?;
// Analyze source code
let src_path = path.join("src");
let modules = self.analyze_modules(&src_path)?;
// Calculate metrics
let metrics = self.calculate_metrics(&modules, &dependencies);
// Analyze structure
let structure = self.analyze_project_structure(path)?;
Ok(ProjectInfo {
name,
description,
version,
authors,
license,
dependencies,
modules,
structure,
metrics,
})
}
fn parse_dependencies(&self, cargo_toml: &toml::Value) -> Result<HashMap<String, String>> {
let mut dependencies = HashMap::new();
if let Some(deps) = cargo_toml.get("dependencies") {
if let Some(deps_table) = deps.as_table() {
for (name, value) in deps_table {
let version = match value {
toml::Value::String(v) => v.clone(),
toml::Value::Table(t) => {
t.get("version")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string()
}
_ => "*".to_string(),
};
dependencies.insert(name.clone(), version);
}
}
}
Ok(dependencies)
}
fn analyze_modules(&self, src_path: &Path) -> Result<Vec<ModuleInfo>> {
let mut modules = Vec::new();
if !src_path.exists() {
return Ok(modules);
}
// Walk through all .rs files
for entry in walkdir::WalkDir::new(src_path) {
let entry = entry?;
if entry.file_type().is_file() {
if let Some(extension) = entry.path().extension() {
if extension == "rs" {
if let Ok(module) = self.analyze_rust_file(entry.path()) {
modules.push(module);
}
}
}
}
}
Ok(modules)
}
fn analyze_rust_file(&self, file_path: &Path) -> Result<ModuleInfo> {
let content = std::fs::read_to_string(file_path)?;
let syntax_tree = syn::parse_file(&content)?;
let mut visitor = RustVisitor::new();
visitor.visit_file(&syntax_tree);
let module_name = file_path
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
// Extract module-level documentation
let docs = self.extract_module_docs(&content);
Ok(ModuleInfo {
name: module_name,
path: file_path.to_path_buf(),
functions: visitor.functions,
structs: visitor.structs,
enums: visitor.enums,
traits: visitor.traits,
docs,
})
}
fn extract_module_docs(&self, content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let mut doc_lines = Vec::new();
let mut in_module_doc = false;
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("//!") {
in_module_doc = true;
doc_lines.push(trimmed.trim_start_matches("//!").trim());
} else if trimmed.starts_with("/*!") {
in_module_doc = true;
let content = trimmed.trim_start_matches("/*!").trim_end_matches("*/").trim();
doc_lines.push(content);
} else if in_module_doc && !trimmed.is_empty() && !trimmed.starts_with("//") {
break;
}
}
if doc_lines.is_empty() {
None
} else {
Some(doc_lines.join("\n"))
}
}
fn calculate_metrics(&self, modules: &[ModuleInfo], dependencies: &HashMap<String, String>) -> ProjectMetrics {
let total_lines = modules.iter().map(|m| {
std::fs::read_to_string(&m.path)
.map(|content| content.lines().count())
.unwrap_or(0)
}).sum();
let total_files = modules.len();
let test_files = modules.iter().filter(|m| {
m.name.contains("test") || m.path.to_string_lossy().contains("/tests/")
}).count();
let dependency_count = dependencies.len();
// Simple complexity calculation based on number of functions and structs
let complexity_score = modules.iter().map(|m| {
(m.functions.len() + m.structs.len() + m.enums.len() + m.traits.len()) as f32
}).sum::<f32>() / modules.len().max(1) as f32;
ProjectMetrics {
total_lines,
total_files,
test_files,
dependency_count,
complexity_score,
test_coverage: None, // TODO: Implement test coverage calculation
}
}
fn analyze_project_structure(&self, path: &Path) -> Result<ProjectStructure> {
let mut directories = Vec::new();
let mut files = Vec::new();
self.walk_directory(path, &mut directories, &mut files)?;
Ok(ProjectStructure {
directories,
files,
dependency_graph: HashMap::new(), // TODO: Implement dependency graph
})
}
fn walk_directory(
&self,
path: &Path,
directories: &mut Vec<DirectoryInfo>,
files: &mut Vec<FileInfo>,
) -> Result<()> {
for entry in walkdir::WalkDir::new(path).max_depth(3) {
let entry = entry?;
let relative_path = entry.path().strip_prefix(path)?;
if entry.file_type().is_dir() && relative_path != Path::new("") {
let file_count = std::fs::read_dir(entry.path())?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.count();
let subdirectories = std::fs::read_dir(entry.path())?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
directories.push(DirectoryInfo {
name: entry.path().file_name().unwrap().to_string_lossy().to_string(),
path: relative_path.to_path_buf(),
file_count,
subdirectories,
});
} else if entry.file_type().is_file() {
let language = match entry.path().extension().and_then(|s| s.to_str()) {
Some("rs") => "rust".to_string(),
Some("toml") => "toml".to_string(),
Some("md") => "markdown".to_string(),
_ => "unknown".to_string(),
};
let lines_of_code = std::fs::read_to_string(entry.path())
.map(|content| content.lines().count())
.unwrap_or(0);
let is_test = entry.path().to_string_lossy().contains("test");
files.push(FileInfo {
name: entry.path().file_name().unwrap().to_string_lossy().to_string(),
path: relative_path.to_path_buf(),
language,
lines_of_code,
is_test,
});
}
}
Ok(())
}
}
struct RustVisitor {
functions: Vec<FunctionInfo>,
structs: Vec<StructInfo>,
enums: Vec<EnumInfo>,
traits: Vec<TraitInfo>,
current_line: usize,
}
impl RustVisitor {
fn new() -> Self {
Self {
functions: Vec::new(),
structs: Vec::new(),
enums: Vec::new(),
traits: Vec::new(),
current_line: 1,
}
}
fn visibility_to_string(&self, vis: &Visibility) -> String {
match vis {
Visibility::Public(_) => "pub".to_string(),
Visibility::Restricted(_) => "pub(restricted)".to_string(),
Visibility::Inherited => "private".to_string(),
}
}
fn extract_docs(&self, attrs: &[syn::Attribute]) -> Option<String> {
let mut docs = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc") {
if let syn::Meta::NameValue(meta) = &attr.meta {
if let syn::Expr::Lit(expr_lit) = &meta.value {
if let syn::Lit::Str(lit_str) = &expr_lit.lit {
docs.push(lit_str.value());
}
}
}
}
}
if docs.is_empty() {
None
} else {
Some(docs.join("\n"))
}
}
}
impl<'ast> Visit<'ast> for RustVisitor {
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let name = node.sig.ident.to_string();
let visibility = self.visibility_to_string(&node.vis);
let is_async = node.sig.asyncness.is_some();
let parameters = node.sig.inputs.iter().map(|input| {
match input {
syn::FnArg::Receiver(_) => Parameter {
name: "self".to_string(),
param_type: "Self".to_string(),
is_mutable: false,
},
syn::FnArg::Typed(typed) => {
let name = match &*typed.pat {
syn::Pat::Ident(ident) => ident.ident.to_string(),
_ => "unknown".to_string(),
};
Parameter {
name,
param_type: quote::quote!(#typed.ty).to_string(),
is_mutable: false, // TODO: Detect mutability
}
}
}
}).collect();
let return_type = match &node.sig.output {
syn::ReturnType::Default => None,
syn::ReturnType::Type(_, ty) => Some(quote::quote!(#ty).to_string()),
};
let docs = self.extract_docs(&node.attrs);
self.functions.push(FunctionInfo {
name,
visibility,
is_async,
parameters,
return_type,
docs,
line_number: self.current_line,
});
syn::visit::visit_item_fn(self, node);
}
fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
let name = node.ident.to_string();
let visibility = self.visibility_to_string(&node.vis);
let docs = self.extract_docs(&node.attrs);
let fields = match &node.fields {
syn::Fields::Named(fields) => {
fields.named.iter().map(|field| {
FieldInfo {
name: field.ident.as_ref().unwrap().to_string(),
field_type: quote::quote!(#field.ty).to_string(),
visibility: self.visibility_to_string(&field.vis),
docs: self.extract_docs(&field.attrs),
}
}).collect()
}
syn::Fields::Unnamed(fields) => {
fields.unnamed.iter().enumerate().map(|(i, field)| {
FieldInfo {
name: format!("field_{}", i),
field_type: quote::quote!(#field.ty).to_string(),
visibility: self.visibility_to_string(&field.vis),
docs: self.extract_docs(&field.attrs),
}
}).collect()
}
syn::Fields::Unit => Vec::new(),
};
self.structs.push(StructInfo {
name,
visibility,
fields,
docs,
line_number: self.current_line,
});
syn::visit::visit_item_struct(self, node);
}
fn visit_item_enum(&mut self, node: &'ast ItemEnum) {
let name = node.ident.to_string();
let visibility = self.visibility_to_string(&node.vis);
let docs = self.extract_docs(&node.attrs);
let variants = node.variants.iter().map(|variant| {
let variant_name = variant.ident.to_string();
let variant_docs = self.extract_docs(&variant.attrs);
let fields = match &variant.fields {
syn::Fields::Named(fields) => {
fields.named.iter().map(|field| {
FieldInfo {
name: field.ident.as_ref().unwrap().to_string(),
field_type: quote::quote!(#field.ty).to_string(),
visibility: self.visibility_to_string(&field.vis),
docs: self.extract_docs(&field.attrs),
}
}).collect()
}
syn::Fields::Unnamed(fields) => {
fields.unnamed.iter().enumerate().map(|(i, field)| {
FieldInfo {
name: format!("field_{}", i),
field_type: quote::quote!(#field.ty).to_string(),
visibility: self.visibility_to_string(&field.vis),
docs: self.extract_docs(&field.attrs),
}
}).collect()
}
syn::Fields::Unit => Vec::new(),
};
VariantInfo {
name: variant_name,
fields,
docs: variant_docs,
}
}).collect();
self.enums.push(EnumInfo {
name,
visibility,
variants,
docs,
line_number: self.current_line,
});
syn::visit::visit_item_enum(self, node);
}
fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
let name = node.ident.to_string();
let visibility = self.visibility_to_string(&node.vis);
let docs = self.extract_docs(&node.attrs);
let methods = node.items.iter().filter_map(|item| {
match item {
syn::TraitItem::Fn(method) => {
let method_name = method.sig.ident.to_string();
let method_visibility = "pub".to_string(); // Trait methods are inherently public
let is_async = method.sig.asyncness.is_some();
let parameters = method.sig.inputs.iter().map(|input| {
match input {
syn::FnArg::Receiver(_) => Parameter {
name: "self".to_string(),
param_type: "Self".to_string(),
is_mutable: false,
},
syn::FnArg::Typed(typed) => {
let name = match &*typed.pat {
syn::Pat::Ident(ident) => ident.ident.to_string(),
_ => "unknown".to_string(),
};
Parameter {
name,
param_type: quote::quote!(#typed.ty).to_string(),
is_mutable: false,
}
}
}
}).collect();
let return_type = match &method.sig.output {
syn::ReturnType::Default => None,
syn::ReturnType::Type(_, ty) => Some(quote::quote!(#ty).to_string()),
};
let method_docs = self.extract_docs(&method.attrs);
Some(FunctionInfo {
name: method_name,
visibility: method_visibility,
is_async,
parameters,
return_type,
docs: method_docs,
line_number: self.current_line,
})
}
_ => None,
}
}).collect();
self.traits.push(TraitInfo {
name,
visibility,
methods,
docs,
line_number: self.current_line,
});
syn::visit::visit_item_trait(self, node);
}
}

View File

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AtprotoClient {
client: reqwest::Client,
handle_resolver: String,
@ -27,8 +28,10 @@ pub struct CommentRecord {
#[serde(rename = "$type")]
pub record_type: String,
pub text: String,
pub createdAt: String,
pub postUri: String,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "postUri")]
pub post_uri: String,
pub author: AuthorInfo,
}
@ -38,6 +41,7 @@ pub struct AuthorInfo {
pub handle: String,
}
#[allow(dead_code)]
impl AtprotoClient {
pub fn new(handle_resolver: String) -> Self {
Self {
@ -59,8 +63,8 @@ impl AtprotoClient {
let record = CommentRecord {
record_type: "app.bsky.feed.post".to_string(),
text: text.to_string(),
createdAt: chrono::Utc::now().to_rfc3339(),
postUri: post_uri.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
post_uri: post_uri.to_string(),
author: AuthorInfo {
did: did.to_string(),
handle: "".to_string(), // Will be resolved by the server

Some files were not shown because too many files have changed in this diff Show More