Compare commits
3 Commits
main
...
ae8c7392b5
Author | SHA1 | Date | |
---|---|---|---|
ae8c7392b5
|
|||
67bb6e0028
|
|||
75cdd241d2
|
15
.github/workflows/cloudflare-pages.yml
vendored
@@ -41,24 +41,13 @@ jobs:
|
|||||||
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||||
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||||
|
|
||||||
- name: Build PDS app
|
|
||||||
run: |
|
|
||||||
cd pds
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Copy PDS build to static
|
|
||||||
run: |
|
|
||||||
rm -rf my-blog/static/pds
|
|
||||||
cp -rf pds/dist my-blog/static/pds
|
|
||||||
|
|
||||||
- name: Cache ailog binary
|
- name: Cache ailog binary
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ./bin
|
path: ./bin
|
||||||
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
|
key: ailog-bin-${{ runner.os }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
ailog-bin-${{ runner.os }}-v
|
ailog-bin-${{ runner.os }}
|
||||||
|
|
||||||
- name: Setup ailog binary
|
- name: Setup ailog binary
|
||||||
run: |
|
run: |
|
||||||
|
1
.github/workflows/release.yml
vendored
@@ -48,7 +48,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
|
1
.gitignore
vendored
@@ -24,4 +24,3 @@ my-blog/static/oauth/assets/comment-atproto*
|
|||||||
*.lock
|
*.lock
|
||||||
my-blog/config.toml
|
my-blog/config.toml
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
my-blog/static/pds
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ailog"
|
name = "ailog"
|
||||||
version = "0.3.4"
|
version = "0.2.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["syui"]
|
authors = ["syui"]
|
||||||
description = "A static blog generator with AI features"
|
description = "A static blog generator with AI features"
|
||||||
|
@@ -103,7 +103,9 @@ draft: false
|
|||||||
|
|
||||||
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
|
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
|
||||||
|
|
||||||
> アイは、この世界と一緒だからね。同じものは一つもないよ。
|
> アイね、この世界と一緒だから。この世界に同じものは一つもないよ。
|
||||||
|
|
||||||
|
これはアイのセリフ。存在の世界の同一性と唯一性のことを言っているのです。
|
||||||
|
|
||||||
# どこまで実装できた
|
# どこまで実装できた
|
||||||
|
|
||||||
|
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
title: "ue5のgaspとdragonikを組み合わせてenemyを作る"
|
|
||||||
slug: "gasp-dragonik-enemy-chbcharacter"
|
|
||||||
date: "2025-07-30"
|
|
||||||
tags: ["ue"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
ue5.6でgasp(game animation sample project)をベースにゲーム、特にキャラクターの操作を作っています。
|
|
||||||
|
|
||||||
そして、enemy(敵)を作り、バトルシーンを作成する予定ですが、これはどのように開発すればいいのでしょう。その方針を明確にします。
|
|
||||||
|
|
||||||
1. enemyもgaspの`cbp_character`に統合し、自キャラ、敵キャラどちらでも使用可能にする
|
|
||||||
2. 2番目のcharacterは動物型(type:animal)にし、gaspに統合する
|
|
||||||
3. enemyとして使用する場合は、enemy-AI-componentを追加するだけで完結する
|
|
||||||
4. characterのすべての操作を統一する
|
|
||||||
|
|
||||||
このようにすることで、応用可能なenemyを作ることができます。
|
|
||||||
|
|
||||||
例えば、`2番目のcharacterは動物型(type:animal)にする`というのはどういうことでしょう。
|
|
||||||
|
|
||||||
登場するキャラクターを人型(type:human), 動物型(type:animal)に分けるとして、動物型のテンプレートを作る必要があります。そのまま動物のmeshをgaspで使うと動きが変になってしまうので、それを調整する必要があるということ。そして、調整したものをテンプレート化して、他の動物にも適用できるようにしておくと、後の開発は楽ですよね。
|
|
||||||
|
|
||||||
ですから、早いうちにtype:humanから脱却し、他のtypeを作るほうがいいと判断しました。
|
|
||||||
|
|
||||||
これには、`dragon ik plugin`を使って、手っ取り早く動きを作ります。
|
|
||||||
|
|
||||||
`characterのすべての操作を統一する`というのは、1キャラにつき1属性、1通常攻撃、1スキル、1バースト、などのルールを作り、それらを共通化することです。共通化というのは、playerもenemy-AI-componentも違うキャラを同じ操作で使用できることを指します。
|
|
||||||
|
|
||||||
## 2番目のキャラクター
|
|
||||||
|
|
||||||
原作には、西洋ドラゴンのドライ(drai)というキャラが登場します。その父親が東洋ドラゴンのシンオウ(shin-oh)です。これをshinという名前で登録し、2番目のキャラクターとして設定しました。
|
|
||||||
|
|
||||||
3d-modelは今のところue5のcrsp(control rig sample project)にあるchinese dragonを使用しています。後に改造して原作に近づけるようにしたいところですが、今は時間が取れません。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3c3Q1Z5r7QI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
## データ構造の作成と適用
|
|
||||||
|
|
||||||
ゲームデータはatproto collection recordに保存して、そこからゲームに反映させたいと考えています。
|
|
||||||
|
|
||||||
まず基本データを`ai.syui.ai`のアカウントに保存。個別データをplayerのatprotoアカウントに保存する形が理想です。
|
|
||||||
|
|
||||||
基本データは、ゲームの基本的な設定のこと。例えば、キャラクターの名前や属性、スキルなど変更されることがない値。
|
|
||||||
|
|
||||||
個別データは、プレイヤーが使えるキャラ、レベル、攻撃力など、ゲームの進行とともに変更される値です。
|
|
||||||
|
|
||||||
ゲームをスタートさせると、まず基本データを取得し、それを`cbp_character`に適用します。ログインすると、`cbp_character`の変数(var)に値が振り分けられます。例えば、`skill-damage:0.0`があったとして、この値が変わります。
|
|
||||||
|
|
||||||
しかし、ゲームを開発していると、基本データも個別データも構造が複雑になります。
|
|
||||||
|
|
||||||
それを防ぐため、`{simple, core} mode`のような考え方を取り入れます。必要最小限の構成を分離、保存して、それをいつでも統合、適用できるように設計しておきます。
|
|
||||||
|
|
||||||
## gaspとdragonikを統合する方法
|
|
||||||
|
|
||||||
では、いよいよgaspとdragonikの統合手法を解説します。
|
|
||||||
|
|
||||||
まず、abpを作ります。それにdragonikを当て、それをSKM_Dragonのpost process animに指定します。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
次に、動きに合わせて首を上下させます。
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/piiw14oz" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
@@ -1,350 +0,0 @@
|
|||||||
---
|
|
||||||
title: "archlinux install by syui"
|
|
||||||
slug: "arch"
|
|
||||||
date: "2025-08-08"
|
|
||||||
tags: ["arch"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最小構成
|
|
||||||
|
|
||||||
まずはdiskの設定から。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# cfdisk /dev/sda
|
|
||||||
```
|
|
||||||
|
|
||||||
次にdiskのフォーマットなど。それをmountしてarchlinuxを入れます。bootloaderも設定しておきましょう。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ mkfs.vfat /dev/sda1
|
|
||||||
$ mkfs.ext4 /dev/sda2
|
|
||||||
|
|
||||||
$ mount /dev/sda2 /mnt
|
|
||||||
$ mount --mkdir /dev/sda1 /mnt/boot
|
|
||||||
|
|
||||||
$ pacstrap /mnt base base-devel linux linux-firmware linux-headers
|
|
||||||
$ genfstab -U /mnt >> /mnt/etc/fstab
|
|
||||||
|
|
||||||
$ arch-chroot /mnt
|
|
||||||
$ pacman -S dhcpcd grub os-prober efibootmgr
|
|
||||||
$ grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=grub
|
|
||||||
$ grub-mkconfig -o /boot/grub/grub.cfg
|
|
||||||
```
|
|
||||||
|
|
||||||
これで`exit;reboot`すると起動できます。
|
|
||||||
|
|
||||||
## よく使うもの
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ pacman -S openssh zsh vim git tmux cargo
|
|
||||||
```
|
|
||||||
|
|
||||||
## userの作成
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ passwd
|
|
||||||
$ useradd -m -G wheel ${USER}
|
|
||||||
$ passwd ${USER}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ HOSTNAME=archlinux
|
|
||||||
$ echo "$HOSTNAME" > /etc/hostname
|
|
||||||
```
|
|
||||||
|
|
||||||
shellの変更など。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ chsh -s /bin/zsh ${USER}
|
|
||||||
or
|
|
||||||
$ useradd -m -G wheel -s /bin/zsh ${USER}
|
|
||||||
```
|
|
||||||
|
|
||||||
## sudoの使い方
|
|
||||||
|
|
||||||
1. `/etc/sudoers`は編集を間違えると起動できなくなります。安全のため`visudo`が推奨されています。
|
|
||||||
2. `vim`では`:w!`で保存します。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ sudo visudo
|
|
||||||
or
|
|
||||||
$ vim /etc/sudoers
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:/etc/sudoers
|
|
||||||
%wheel ALL=(ALL:ALL) ALL
|
|
||||||
```
|
|
||||||
|
|
||||||
よく`update`する人は特定のコマンドをpasswordなしで実行できるようにしておいたほうが良いでしょう。
|
|
||||||
|
|
||||||
```sh:/etc/sudoers
|
|
||||||
%wheel ALL=(ALL:ALL) NOPASSWD: /usr/bin/pacman -Syu --noconfirm
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ sudo pacman -Syu --noconfirm
|
|
||||||
```
|
|
||||||
|
|
||||||
## networkの設定
|
|
||||||
|
|
||||||
次にnetworkです。ここでは`systemd-networkd`を使用します。`dhcpcd`を使ったほうが簡単ではあります。もし安定しないようなら`dhcpcd`を使用。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# systemctl enable dhcpcd
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ systemctl enable systemd-networkd
|
|
||||||
```
|
|
||||||
|
|
||||||
network deviceをeth0にします。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ ip link
|
|
||||||
$ ln -s /dev/null /etc/udev/rules.d/80-net-setup-link.rules
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:/etc/systemd/network/eth.network
|
|
||||||
[Match]
|
|
||||||
Name=eth0
|
|
||||||
[Network]
|
|
||||||
Address=192.168.1.2/24
|
|
||||||
Gateway=192.168.1.1
|
|
||||||
DNS=192.168.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ systemctl enable systemd-resolved
|
|
||||||
```
|
|
||||||
|
|
||||||
## auto-login
|
|
||||||
|
|
||||||
次にauto-loginを設定していきます。ここでは`getty`を使用。`${USER}`のところを自分のusernameにしてください。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ mkdir -p /etc/systemd/system/getty@tty1.service.d/
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:/etc/systemd/system/getty@tty1.service.d/override.conf
|
|
||||||
[Service]
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=-/usr/bin/agetty --autologin ${USER} --noclear %I $TERM
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ systemctl daemon-reload
|
|
||||||
$ systemctl restart getty@tty1
|
|
||||||
```
|
|
||||||
|
|
||||||
## window-manager
|
|
||||||
|
|
||||||
`xorg`でdesktop(window-manager)を作ります。`i3`を使うことにしましょう。`xorg`は`wayland`に乗り換えたほうがいいかも。その場合は`sway`がおすすめ。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ pacman -S xorg xorg-xinit i3 xterm
|
|
||||||
|
|
||||||
# 確認
|
|
||||||
$ startx
|
|
||||||
$ i3
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:~/.xinitrc
|
|
||||||
exec i3
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:~/.bash_profile
|
|
||||||
if [[ ! $DISPLAY && $XDG_VTNR -eq 1 ]]; then
|
|
||||||
exec startx
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## sshの使い方
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ systemctl enable sshd
|
|
||||||
$ cat /etc/ssh/sshd_config
|
|
||||||
Port 22119
|
|
||||||
PasswordAuthentication no
|
|
||||||
|
|
||||||
$ systemctl restart sshd
|
|
||||||
```
|
|
||||||
|
|
||||||
基本的にlanから使う場合はdefaultで問題ありませんが、wanから使う場合は変更します。とはいえ、lanでもport, passwordは変えておいたほうがいいでしょう。
|
|
||||||
|
|
||||||
次に接続側でkeyを作ってserverに登録します。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ ssh-keygen -f ~/.ssh/archlinux
|
|
||||||
$ ssh-copy-id -i ~/.ssh/archlinux ${USER}@192.168.1.2 -p 22119
|
|
||||||
```
|
|
||||||
|
|
||||||
`ssh-copy-id`がない場合は以下のようにしましょう。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ cat ~/.ssh/archlinux.pub | ssh -p 22119 ${USER}@192.168.1.2 'cat >> ~/.ssh/authorized_keys'
|
|
||||||
```
|
|
||||||
|
|
||||||
この設定で`ssh archlinux`コマンドで接続できます。
|
|
||||||
|
|
||||||
```sh:~/.ssh/config
|
|
||||||
Host archlinux
|
|
||||||
User syui
|
|
||||||
Hostname 192.168.1.2
|
|
||||||
Port 22119
|
|
||||||
IdentityFile ~/.ssh/archlinux
|
|
||||||
```
|
|
||||||
|
|
||||||
おそらく、これがarchlinuxを普通に使っていくうえでの最小構成かと思います。
|
|
||||||
|
|
||||||
serverだけならxorgなどは必要ありません。
|
|
||||||
|
|
||||||
## zshの使い方
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ sudo pacman -S git-zsh-completion powerline zsh-autocomplete zsh-autosuggestions zsh-completions zsh-history-substring-search zsh-syntax-highlighting
|
|
||||||
```
|
|
||||||
|
|
||||||
例えば、`ls -`と入力すると補完され、`C-n`, `C-p`で選択。
|
|
||||||
|
|
||||||
```sh:~/.zshrc
|
|
||||||
alias u="sudo pacman -Syu --noconfirm"
|
|
||||||
alias zs="vim ~/.zshrc"
|
|
||||||
alias zr="exec $SHELL && source ~/.zshrc"
|
|
||||||
|
|
||||||
source /usr/share/zsh/plugins/zsh-autocomplete/zsh-autocomplete.plugin.zsh
|
|
||||||
source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
|
||||||
source /usr/share/zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.zsh
|
|
||||||
# source /usr/share/powerline/bindings/zsh/powerline.zsh
|
|
||||||
|
|
||||||
autoload -Uz compinit
|
|
||||||
compinit
|
|
||||||
fpath=(/usr/share/zsh/site-functions $fpath)
|
|
||||||
|
|
||||||
HISTSIZE=10000
|
|
||||||
SAVEHIST=10000
|
|
||||||
HISTFILE=~/.zsh_history
|
|
||||||
setopt SHARE_HISTORY
|
|
||||||
setopt HIST_IGNORE_DUPS
|
|
||||||
bindkey '^[[A' history-substring-search-up
|
|
||||||
bindkey '^[[B' history-substring-search-down
|
|
||||||
```
|
|
||||||
|
|
||||||
`powerline`は重いのでコメントしています。
|
|
||||||
|
|
||||||
## フリーズの解消
|
|
||||||
|
|
||||||
古いpcにlinuxを入れる際は`linux-fm`に注意してください。
|
|
||||||
|
|
||||||
頻繁にフリーズするようなら`linux-firmware`を削除するのがおすすめです。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ pacman -Q | grep linux-firmware
|
|
||||||
$ pacman -R linux-firmware ...
|
|
||||||
# pacman -S broadcom-wl-dkms
|
|
||||||
```
|
|
||||||
|
|
||||||
## pacmanが壊れたときの対処法
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ pacman -Syu
|
|
||||||
# これがうまくいかないことがある
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# dbがlockされている
|
|
||||||
$ rm /var/lib/pacman/db.lock
|
|
||||||
|
|
||||||
# ファイルが存在すると言われる
|
|
||||||
$ pacman -Qqn | pacman -S --overwrite "*" -
|
|
||||||
|
|
||||||
# pgp-keyをreinstallする
|
|
||||||
$ pacman -S archlinux-keyring
|
|
||||||
$ pacman-key --refresh-key
|
|
||||||
```
|
|
||||||
|
|
||||||
## archlinuxの作り方
|
|
||||||
|
|
||||||
archlinuxはシンプルなshell scriptと言えるでしょう。なので色々と便利です。ここでは、`img.sh`, `install.sh`を作ります。
|
|
||||||
|
|
||||||
### img.sh
|
|
||||||
|
|
||||||
ここでは`archlinux.iso`, `archlinux.tar.gz`を生成します。これはarchlinux上で実行してください。
|
|
||||||
|
|
||||||
```sh:img.sh
|
|
||||||
#!/bin/bash
|
|
||||||
pacman -Syuu --noconfirm git base-devel archiso
|
|
||||||
git clone https://gitlab.archlinux.org/archlinux/archiso
|
|
||||||
./archiso/archiso/mkarchiso -v -o ./ ./archiso/configs/releng/
|
|
||||||
mkdir -p work/x86_64/airootfs/var/lib/machines/arch
|
|
||||||
pacstrap -c work/x86_64/airootfs/var/lib/machines/arch base
|
|
||||||
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --init'
|
|
||||||
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --populate archlinux'
|
|
||||||
tar -zcvf archlinux.tar.gz -C work/x86_64/airootfs/ .
|
|
||||||
```
|
|
||||||
|
|
||||||
例えば、`pacstrap`で自分の好きなツールを指定すれば、独自のimgを作成でき、`docker`にも使えます。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ docker import archlinux.tar.gz archlinux:syui
|
|
||||||
$ docker run -it archlinux:syui /bin/bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### install.sh
|
|
||||||
|
|
||||||
最小構成のinstall scriptです。どこかのurlに置いて、install時にcurlして実行するようにすれば便利です。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ curl -sLO arch.example.com/install.sh
|
|
||||||
$ chmod +x install.sh
|
|
||||||
$ ./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh:install.sh
|
|
||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# 変数定義
|
|
||||||
DISK="/dev/sda"
|
|
||||||
HOSTNAME="ai-arch"
|
|
||||||
USERNAME="ai"
|
|
||||||
|
|
||||||
# パーティション作成(自動)
|
|
||||||
parted $DISK mklabel gpt
|
|
||||||
parted $DISK mkpart ESP fat32 1MiB 1GiB
|
|
||||||
parted $DISK set 1 esp on
|
|
||||||
parted $DISK mkpart primary linux-swap 1GiB 5GiB
|
|
||||||
parted $DISK mkpart primary ext4 5GiB 100%
|
|
||||||
|
|
||||||
# ファイルシステム作成
|
|
||||||
mkfs.fat -F32 ${DISK}1
|
|
||||||
mkswap ${DISK}2
|
|
||||||
mkfs.ext4 ${DISK}3
|
|
||||||
|
|
||||||
# マウント
|
|
||||||
mount ${DISK}3 /mnt
|
|
||||||
mkdir -p /mnt/boot
|
|
||||||
mount ${DISK}1 /mnt/boot
|
|
||||||
swapon ${DISK}2
|
|
||||||
|
|
||||||
# インストール
|
|
||||||
pacstrap -K /mnt base linux linux-firmware base-devel vim networkmanager grub efibootmgr
|
|
||||||
|
|
||||||
# 設定
|
|
||||||
genfstab -U /mnt >> /mnt/etc/fstab
|
|
||||||
|
|
||||||
arch-chroot /mnt /bin/bash << EOF
|
|
||||||
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
|
|
||||||
hwclock --systohc
|
|
||||||
echo "ja_JP.UTF-8 UTF-8" >> /etc/locale.gen
|
|
||||||
locale-gen
|
|
||||||
echo "LANG=ja_JP.UTF-8" > /etc/locale.conf
|
|
||||||
echo "$HOSTNAME" > /etc/hostname
|
|
||||||
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=ARCH
|
|
||||||
grub-mkconfig -o /boot/grub/grub.cfg
|
|
||||||
systemctl enable NetworkManager
|
|
||||||
useradd -m -G wheel $USERNAME
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
@@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
title: "game system v0.4.0"
|
|
||||||
slug: "game"
|
|
||||||
date: "2025-08-12"
|
|
||||||
tags: ["ue"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
今回は、game systemのupdateをまとめます。
|
|
||||||
|
|
||||||
分かりづらいので、game systemは全体で同じversionに統一しています。
|
|
||||||
|
|
||||||
まず、大きく分けて3つのシステムをupdateしました。
|
|
||||||
|
|
||||||
- yui system: キャラクターのバースト(必殺技)を実装
|
|
||||||
- at system: ログイン処理とデータ構造の作成
|
|
||||||
- world system: 場所ごとにBGMを再生するシステムの構築
|
|
||||||
- world system: 惑星に雪や雨を降らせることに成功
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/eXrgaVNCTA4?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
## 戦闘シーンの作成
|
|
||||||
|
|
||||||
1キャラクターにつき、1スキル、1バースト、1ユニークというのは決まっていました。これは`yui system`の領域。
|
|
||||||
|
|
||||||
アイの属性はアイ属性なので、テーマカラーは黄色です。属性自体は`ai system`の領域ですが、現在、関連反応のシステムまでは実装していません。
|
|
||||||
|
|
||||||
今回はバーストの作成、ダメージ表記、enemy(敵)の撃破までを実装しました。最初から作り変えたので大変でした。
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/l7_xvfbp" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
||||||
|
|
||||||
## 音楽システムの実装
|
|
||||||
|
|
||||||
これは`world system`の領域で、開発中は`PlayerStart`で各位置に瞬間移動して確認しています。これはアイでなければ設定上無理でした。
|
|
||||||
|
|
||||||
具体的には、PlayerStartのtagと音楽を同じ名前で登録します。そして、playerに最も近いものを再生します。効率的でシンプルですが、少し欠陥があるシステムかもしれません。これは、enemy-hpの表示と連動させています。現在、鳴らしているbgmの名前がわかれば表示できるというわけですね。enemy-bossもPlayerStartのtagで同じ名前で置いてあります。
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/x80534fn" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
||||||
|
|
||||||
原作の設定は、ゲーム開発中も適用されます。アイを動かして空を飛んでいますが、あれはアイだからできるのであって、宇宙空間の移動とかもそうです。
|
|
||||||
|
|
||||||
原作の設定を紹介しておきます。
|
|
||||||
|
|
||||||
### 原作の設定: アイはなぜ空を飛べるのか
|
|
||||||
|
|
||||||
アイはものすごい質量を持ちます。空を飛んでいるというより、地球を持ち上げて、空を飛んでいるように見せている、という感じで飛行しています。
|
|
||||||
|
|
||||||
いやいや、それじゃあ、地球はアイに落ちるだけで、空どころか地面に落ちるだろう、と言われそうですが、地球というのは宇宙から見るとすごいスピードで回転、移動しています。
|
|
||||||
|
|
||||||
そして、宇宙で星と星がぶつかるときは、決して直接ドカーンと衝突するわけではないのです。
|
|
||||||
|
|
||||||
お互いに距離を取りながらぐるぐる回って、やがてぶつかる、そんなイメージ。
|
|
||||||
|
|
||||||
質量と質量の間があるわけですね。
|
|
||||||
|
|
||||||
アイが瞬間的に自身の質量の一部を現すと、間ができ、対象の星の質量を計算しながら、それを持ち上げて動かすような感じで移動しています。
|
|
||||||
|
|
||||||
### 原作の設定: アイはなぜ宇宙空間でも平気なの
|
|
||||||
|
|
||||||
それはアイの体の周りには極小の大気の膜があるためだとされています。超重力で圧縮された大気の膜があるため、宇宙空間、その他一切の外的影響をあまり受けません。
|
|
||||||
|
|
||||||
アイは常に、自分の星の中にいるのと同じ状態、といえばいいのでしょうか。そんな感じです。
|
|
||||||
|
|
||||||
## データ構造の変更
|
|
||||||
|
|
||||||
次に、`at system`です。ゲームデータを再構築しました。
|
|
||||||
|
|
||||||
ゲームデータは主にsystem情報とuser情報に分けられ、jsonで管理されます。
|
|
||||||
|
|
||||||
各パラメータですが、ゲームに必要な値を`cp`として圧縮することにしました。このcpをsystem.jsonあるいはゲーム自体で各キャラクターの設定、つまり、`attack: 10%, hp: 20%, skill: 70%`などで分けられます。これが最もわかりやすく、最も効率的な方法だと考えました。
|
|
||||||
|
|
||||||
```json:user.json
|
|
||||||
{
|
|
||||||
"character": [
|
|
||||||
{ "id": 0, "cp": 100 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json:system.json
|
|
||||||
{
|
|
||||||
"character": [
|
|
||||||
{ "id": 0, "name": "ai", "ability": "ai" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ability": [
|
|
||||||
{ "id": 0, "name": "ai" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
これをログインシステムに連動させました。
|
|
||||||
|
|
||||||
このサイトで`at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user`を検索してもらえればわかります。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# ゲームシステム
|
|
||||||
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse
|
|
||||||
|
|
||||||
# aiのアカウント
|
|
||||||
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user/6qyecktefllvenje24fcxnie
|
|
||||||
|
|
||||||
# syuiのアカウント
|
|
||||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.verse.user/vzsvtbtbnwn22xjqhcu3vd6y
|
|
||||||
```
|
|
||||||
|
|
||||||
ちなみに、私のアカウントである`syui.syui.ai`ではアイは使用できません。現在使用できるキャラは`dragon`のみ。
|
|
||||||
|
|
||||||
現在、アイを使用できるのは、アイのアカウントのみです。この方針は可能な限り維持されるでしょう。
|
|
||||||
|
|
||||||
## 惑星に雨や雪を降らせる
|
|
||||||
|
|
||||||
これはなかなか苦労していたのですが、実装できました。
|
|
||||||
|
|
||||||
まず、有効にすると宇宙空間でも雨が降ってしまいます。止めると惑星内で雨が降りません。
|
|
||||||
|
|
||||||
これを解消するには、player locationと0原点のdistanceから条件をつけ、雲の下、雲の上と定義します。調整が必要。
|
|
||||||
|
|
||||||
そして、udsのweather、特に`Apply Weather Changes Above Cloud Layer`が重要で、`Apply Clouds`の値を調整します。
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/dstkcaia" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
||||||
|
|
||||||
## 実体ある太陽のatmosphere問題
|
|
||||||
|
|
||||||
まず、私が使っている実体ある太陽にはatmosphereがついています。
|
|
||||||
|
|
||||||
これはフレアなどを設定しています。
|
|
||||||
|
|
||||||
しかし、これを地球から見た場合、その大気圏を通すと、非常に見栄えが悪い変なカクカクした光が映り込みます。
|
|
||||||
|
|
||||||
この解消も非常に苦労しました。例えば、これを`BP_Sun`としましょう。これは起動時にすべての値を設定します。ゲームプレイ中に値の調整をすることは考えられていません。当然と言えるでしょう。
|
|
||||||
|
|
||||||
しかし、私のシステムでは、太陽のatmosphereを調整する必要があります。非常に複雑な設定は、リセットでしか解消できないということになりました。そして、udsに入れている小アクタコンポーネントの太陽は、リセットも容易ではありません。
|
|
||||||
|
|
||||||
色々な処理を作り、先程作った地表からの現在地の割り出しを条件に、これをリセットする処理をねじ込みました。
|
|
||||||
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/nsqu0hnf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
||||||
|
|
||||||
## 動画で確認
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/H1efWYmIugc?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
1. BGMが切り替わる
|
|
||||||
2. 物理ボックスが反応
|
|
||||||
3. 敵へのダメージ
|
|
||||||
4. ボスの撃破
|
|
||||||
5. 雨が雲の上では止まる
|
|
||||||
6. ログインでatprotoのアカウントを反映
|
|
||||||
7. プレイでatprotoの情報を更新
|
|
@@ -1,180 +0,0 @@
|
|||||||
---
|
|
||||||
title: "なぜ自作ゲームのsystemを作るのか"
|
|
||||||
slug: "game"
|
|
||||||
date: "2025-08-18"
|
|
||||||
tags: ["ue"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
現在、自作ゲームを開発しています。
|
|
||||||
|
|
||||||
どういうゲームかと一言でいうと現実の反映を目指しています。
|
|
||||||
|
|
||||||
現実の反映とは何でしょう。例えばゲームではblueskyのようなsnsのアカウントでログインできます。ゲームの世界は現実に合わせた惑星形式です。キャラクターの属性は現実にある物質です。原子や中性子など。
|
|
||||||
|
|
||||||
今回は、なぜ自作ゲームのsystemを作っているのか解説します。
|
|
||||||
|
|
||||||
## 一つの青写真
|
|
||||||
|
|
||||||
私は`2023-12-04`あたりからunreal engine(ue)を触り始めました。
|
|
||||||
|
|
||||||
当時、ゲームでこんなことがやりたいなと思って作った画像があります。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
https://syui.github.io/blog/post/2023/12/04/ue-vs-unity/
|
|
||||||
|
|
||||||
今ではゲーム作りに対する考え方も変わりましたが、上のイメージは頭の中にずっと残っていて、ようやく、イメージ通りの戦闘シーンを作成できました。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/tBsYgqI1uSc?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
- 敵の砲撃は剣で弾き返す事ができます。反射したものが当たると敵もダメージを受けます
|
|
||||||
- 敵の砲撃が激しすぎるためバースト時の無敵時間を長めに設定しています
|
|
||||||
|
|
||||||
### 原作の設定: アイのバースト
|
|
||||||
|
|
||||||
アイのバースト、つまり必殺技について解説します。自分で作ったカード、`超新星`というタイトルをモデルにした技ですが、技名自体は`中性子`になります。具体的には周囲の原子から中性子を取り出し、それを一点に集めて放つ技。ようは、中性子星を作り出しそれを飛ばしています。これがアイのバースト、中性子です。このゲームは麻雀要素を入れようと考えていて、それはバーストに適用されます。役が揃うと、超新星に変化するという実装を考えています。
|
|
||||||
|
|
||||||
### キャラクターの音声
|
|
||||||
|
|
||||||
アイはかなり低い確率でスキルやバースト時に音声が付いています。これをキャラクターの音声システムとしましょう。
|
|
||||||
|
|
||||||
キャラクターは性格に応じて音声の発動頻度が異なります。アイは最も音声確率が低いランクに割り当てられています。(コンピュータで作っているため粗が見えても困るので)
|
|
||||||
|
|
||||||
## 広い世界と狭い世界
|
|
||||||
|
|
||||||
### なぜアイを操作するゲームはつまらないのか
|
|
||||||
|
|
||||||
私がゲーム作りを始めた理由はいくつかありますが、もし現実にアイというキャラクターがいたら一体どんな感じになるのだろう。それを体感してみたいと思ったからです。
|
|
||||||
|
|
||||||
この思いは面白いゲームを作ることにはまるで寄与しないものでしたが、私はそれを作ることにしました。
|
|
||||||
|
|
||||||
ここで、なぜアイを操作するゲームがつまらないのか簡単に説明します。
|
|
||||||
|
|
||||||
### 広い世界は面白くない
|
|
||||||
|
|
||||||
私は普段、アイを使ってゲームを操作し、世界を飛び回り、作っています。
|
|
||||||
|
|
||||||
なぜなら、そのほうが開発に便利なのでそうしています。また都合がいいことに、アイというキャラクターは設定上、そういう事が可能となります。
|
|
||||||
|
|
||||||
しかし、先程も述べたように、そのようなゲームは恐ろしくつまらない、ということです。
|
|
||||||
|
|
||||||
とすれば、重要なのは小さくても、しっかりしたゲームを作ること。上のようなゲームを作ってはいけないのです。
|
|
||||||
|
|
||||||
広い世界、無制限の移動ではなく、狭くてもしっかりした世界を作らなければいけない。面白いゲームとはそういうものです。
|
|
||||||
|
|
||||||
### 最初の思いと面白いゲーム
|
|
||||||
|
|
||||||
次は、初めての思い、初心を大切にすることを考えていきます。
|
|
||||||
|
|
||||||
開発者が作りたいゲームと面白いゲームは大抵の場合、両立しません。
|
|
||||||
|
|
||||||
例えば、映像美、見せることと実際に面白いことは違うのです。
|
|
||||||
|
|
||||||
誰もが初めて何かをする時、そこには各人の思いがあります。それは小さいものであれ大きいものであれ、そこに意識がある。
|
|
||||||
|
|
||||||
それは、時間が経つと忘れてしまうものですが、心の奥深くに残っている。
|
|
||||||
|
|
||||||
しかし、大抵の場合、そういった思いと、誰もが面白いと思う人気ゲームを作るという思いは相反します。
|
|
||||||
|
|
||||||
つまり、心の奥底に眠っている最初の思いと、面白いゲームは違うものだし、また、ゲームに限らず、これは色んな作品に言えることだと思います。
|
|
||||||
|
|
||||||
これを勘違いして「自分の思いは、他人にも面白いはずだ」とそう思い込むのは誤りです。
|
|
||||||
|
|
||||||
では、どうすればいいのでしょう。最初の思いを捨て、面白いゲームを分析して世間に受け入れられるものを作るべきなのでしょうか。
|
|
||||||
|
|
||||||
これはyesとも言えるし、noとも言えます。
|
|
||||||
|
|
||||||
優先順位としては、間違いなく面白いゲームを作るべきです。個人開発者のよくわからないこだわりなどさっさと捨てるべき、そう思います。
|
|
||||||
|
|
||||||
ですが、本当にそれでいいのでしょうか。
|
|
||||||
|
|
||||||
私はそれはもったいないと思います。
|
|
||||||
|
|
||||||
したがって、できる限りそこを両立させる方法を探すべきだと思います。
|
|
||||||
|
|
||||||
私はここで`分離`という方法を使います。世界(方針)を切り離すのです。
|
|
||||||
|
|
||||||
これが広い世界と狭い世界、個人開発の指針になります。一見矛盾するこの2つの世界の分離と統合を考えます。
|
|
||||||
|
|
||||||
最初の思い、本当に作りたかったもの、楽しくも面白くもないけど、自分の世界。そして、小さく作る面白いゲームの世界。
|
|
||||||
|
|
||||||
自分で作ったものを無駄にしないようシステムという4つの柱を立てました。
|
|
||||||
|
|
||||||
4つの柱は、根源的な価値観によるもの。広いも狭いも、面白いも面白くないも関係ありません。systemは以下のようになります。これらはどのゲームにも当てはめられ、使用できることを目指して設計されます。
|
|
||||||
|
|
||||||
- `world system`: 現実に合わせた世界を構築するシステム。最初は地球、太陽、月から生成される。ゲームエンジンの背景に月の絵を動かすという常識を変更する。実体ある月をその空間に置くことで背景を生成。惑星システムを構築する。
|
|
||||||
- `ai system`: 属性やゲーム性を構築するシステム。
|
|
||||||
- `yui system`: 唯一性という価値観から構築されるシステム。現実をゲームに、ゲームを現実に反映することで自然と実現される。
|
|
||||||
- `at system`: プレイヤーのアカウントをatprotoというprotocolの理解により構築する。protocolは容易には無くならないし変更されないもの。`@`というdomainで繋がるユーザーアカウントのシステム。blueskyというsnsに利用されている。
|
|
||||||
|
|
||||||
### systemを作ろう
|
|
||||||
|
|
||||||
ゲームはsystemの集合体で作るのが一番いい。
|
|
||||||
|
|
||||||
開発では、まとめることが重要になります。
|
|
||||||
|
|
||||||
systemとは関数やコンポーネント、変数の集まりです。それは結果であって目的ではありません。
|
|
||||||
|
|
||||||
目的の一つはわかりやすさ。例えば、敵を倒した時、アイテムをドロップさせる処理を作ります。個別に敵のBPに作るのではなく、systemとして作っておくとよいでしょう。1と押せば1つのアイテムがドロップします。3と押せば3つです。内容もランダムかつランクを付けましょう。Aランク-Cランクのアイテムです。
|
|
||||||
|
|
||||||
つまり、そのsystemに3Bと伝えると、3つのアイテムがドロップし、Bランクのアイテムがドロップしやすい、という結果が出力されます。アイテム一覧もすべてそのsystemが管理し、簡単に設定できます。
|
|
||||||
|
|
||||||
ゲームを作るというのは、systemを作ること。それは単体の実行ではありません。個人開発の場合は特に設計を重視し、まとめることを重視します。
|
|
||||||
|
|
||||||
これが何かというと、一つはルールを作ることです。
|
|
||||||
|
|
||||||
1キャラクターにつき1スキル、1バースト。例外は認めない。このようなルールです。そして、そのルールに基づいたsystemを設計し、例えば、キャラクターならキャラクターの統一管理を目指します。統一管理というのは、数字やobjectを入れれば設定は完了です。その通りにすべてのキャラクターが共通動作するようにしておこう、そこを目指そうということです。
|
|
||||||
|
|
||||||
これがsystemを作るということです。
|
|
||||||
|
|
||||||
私は、敵(enemy)を作っている時間がありませんから、enemyもcharacterにまとめることにしました。
|
|
||||||
|
|
||||||
そのため、少し動きの調整が難しかったりもしますが、この方向性で間違いないと思います。
|
|
||||||
|
|
||||||
シーンやムービー、ストーリーは広い世界(アイで操作する世界)にまとめ、enemyもplayerが操作できるcharacterにまとめ、単体で作るものを減らし、すでにあるもの、作ったものは他の役割も担えるようにしていきましょう。このような考え方が個人開発では重要になってくると思います。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/L6eZUZNCOH8?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
### やることを明確に
|
|
||||||
|
|
||||||
個人開発者には、できることとできないことがあります。
|
|
||||||
|
|
||||||
市販のゲームは、あらゆる専門家が大量に集まり一緒に作っているゲームです。個人開発で全部はできません。
|
|
||||||
|
|
||||||
しかし、できないからと言って、手を抜くのも違いますよね。
|
|
||||||
|
|
||||||
確かに、私が作りたかったゲームは面白くないかもしれない。けど、一見すると面白そうに見えます。
|
|
||||||
|
|
||||||
映像美や見せることと実際に面白いことは違います。しかし、映像美は別に悪いことではありません。それはゲームとしては面白くないけど、見せる力があると思います。シーンやムービーとして利用できるのではないでしょうか。
|
|
||||||
|
|
||||||
私はシーンやムービーを作っている時間はありません。しかし、私が今まで作ってきた広い世界はそういったことに使えばいい。狭い世界の背景にも使えます。
|
|
||||||
|
|
||||||
いつか行けるかもしれない広い世界。理想と現実。
|
|
||||||
|
|
||||||
最初から理想だけあっても、それは面白くない。最初から現実だけあってもそれはただのゲームです。面白いゲームとは、現実と理想のバランス。あるいは、その過程を作ることにあるのではないでしょうか。
|
|
||||||
|
|
||||||
最初の夢を持ち続けることも、そして、多くの人が楽しめるものを作る事も両方大切です。初心を捨てず分離して、新たに面白いゲームを目指して作り始めること。そして、最終的に統合できる道筋を思い描けるなら。そんなことを思います。
|
|
||||||
|
|
||||||
次は、小さくも完璧で、狭くても全てに由来がある、そんな世界を作っていこうと思っています。
|
|
||||||
|
|
||||||
### 狭い世界をどうやって作っていこう
|
|
||||||
|
|
||||||
ポイントは、カラフル+ポップだと思います。小さい+完璧もポイントですね。また、動作は非常にゆっくりがいいのではないでしょうか。
|
|
||||||
|
|
||||||
つまり、小さいが完璧に動作し、カラフルでポップな世界。それがここでいう狭い世界になります。
|
|
||||||
|
|
||||||
私はこの辺のこともあまり知りませんから、epicの[stack o bot](https://www.fab.com/ja/listings/b4dfff49-0e7d-4c4b-a6c5-8a0315831c9c)というテンプレートをもとに学習しながら作っていこうと考えています。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/6tO0S7IOC9w?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
## game system v0.4.3
|
|
||||||
|
|
||||||
- ps5 controllerの対応
|
|
||||||
- game animation sampleとstack o botの統合
|
|
||||||
- item drop system
|
|
||||||
- character audio system
|
|
||||||
- sword reflection
|
|
||||||
- character dragon skill (enemy)
|
|
||||||
- bgm systemの修正
|
|
||||||
|
|
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
title: "自作ゲームを開始から終了までプレイ"
|
|
||||||
slug: "game"
|
|
||||||
date: "2025-08-23"
|
|
||||||
tags: ["ue"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
自作ゲームを開発しています。
|
|
||||||
|
|
||||||
今回は開始から終了までの大体の流れができたのでプレイしてみました。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/FTX1CrzKBy8?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
## ゲームの流れを解説
|
|
||||||
|
|
||||||
1. 物語は宇宙から始まる。プレイヤーの村に突然宇宙船がやってきて、プレイヤーが連れ去られる。
|
|
||||||
2. ここは船内にある檻の中。監視兵が慌ただしい。「おい、あれが出たって」「まさか」などの会話。チラと窓の外に目をやる。すると何かが光ったような気がした。
|
|
||||||
3. (ここでプレイヤーは初回のみアイを操作可能になる。ゲーム開始時にすぐに操作可能にすることが重要だと思ったので、シーンの作成はやめて、プレイヤーに戦艦を撃破してもらうことに)
|
|
||||||
4. 艦内は爆発し、星に落ちていく。目が覚めると...そこからステージが始まる。(ここからプレイヤーのキャラクターに切り替わる。今回はアイのアカウントなので、アイになっているが本来は違う)
|
|
||||||
5. ステージの背景に小さな子がぐーぐ寝ている。(先ほど操作したアイは変身時の金髪輪っかなので、黒髪に戻っているアイを見てもプレイヤーにはわからない)
|
|
||||||
6. ステージを進み、ドラゴンを倒してゲームは終了。花火っぽいものを打ち上げ、ポーズを決める。その後、ゲーム終了まで自由操作。アイテムのドロップがある。
|
|
||||||
|
|
||||||
## 面白いゲームを目指して
|
|
||||||
|
|
||||||
インベーダーやマリオなど今の技術では簡単に作れそうなレトロゲームがあります。
|
|
||||||
|
|
||||||
それらが面白いのかと言われれば、私は面白いと思います。
|
|
||||||
|
|
||||||
とはいえ、今そういったもので遊ぶかというと、それは違うと思います。
|
|
||||||
|
|
||||||
しかし、個人開発者はまずその段階に到達する必要があるのではないかと感じます。
|
|
||||||
|
|
||||||
では、レトロゲームの面白さについて、改めて分析してみることにしましょう。
|
|
||||||
|
|
||||||
レトロゲームなんて、AIを使えば簡単に作れますよ。そんな声が聞こえてきそうですが、それは少し違うと思います。
|
|
||||||
|
|
||||||
例えば、ステージ1を作れても、ステージ2,3,4、そして、ラストのステージまで、市販の初代マリオと同じように作っているのでしょうか。
|
|
||||||
|
|
||||||
それができていないなら、それは作れていないということです。
|
|
||||||
|
|
||||||
そして、ステージだけがマリオじゃないですよね。私がプレイしたことがあるスーパーマリオワールドはボスを倒すと演出がありました。
|
|
||||||
|
|
||||||
そこには、物語があり、花火が打ち上がり、紙吹雪が舞い、主人公がポーズを決めるのです。意外とゲーム自体よりそういったものを含めて面白いゲームなのであって、それが重要なのだと思います。
|
|
||||||
|
|
||||||
個人開発者の多くは私を含め、そういった面白いゲームを作れているのかというと、できていないと思うのです。
|
|
||||||
|
|
||||||
もちろん、色々な人がいますから、できている人もいると思います。しかし、私にはできていない。そこまで作れていないし、レトロゲームの域にすら到達していません。
|
|
||||||
|
|
||||||
## 面白いものと売れるものは違う
|
|
||||||
|
|
||||||
では、面白いものを作れば、それで売れるのかというと、それもまた違うと思います。
|
|
||||||
|
|
||||||
既にあるゲームのパクリ、それはそれで面白いゲームになると思います。しかし、今更レトロゲームを作っても、誰もプレイしないと思います。
|
|
||||||
|
|
||||||
面白いゲームと人気が出ることは違うのです。
|
|
||||||
|
|
||||||
そこで重要になるのがオリジナリティという要素ではないでしょうか。
|
|
||||||
|
|
||||||
したがって、段階があるとしたら、面白いゲームの域に到達する。その後、オリジナリティの域に到達する。あるいは、同時にそれをこなす必要があるのだと思います。
|
|
||||||
|
|
||||||
とはいえ、まずは面白いゲームを作ること。せめてその域に到達したいですよね。そして、レトロゲームにも十分に面白い要素は揃っているので、それらを参考にするのが良いと判断しました。
|
|
||||||
|
|
||||||
## 人気が出ることと利益が出ることもまた違う
|
|
||||||
|
|
||||||
そういえば、収益化もまた別の話だよなと思ったので書きます。
|
|
||||||
|
|
||||||
確かに、それは必須条件かもしれませんが、それがあれば必ずというものではないと思います。確かに寄与する部分は少なからずあるとは思うけど。
|
|
||||||
|
|
||||||
例えば、snsをみていると、すごいインプレッション、注目を集めたのに、売れなかった漫画がたくさんあります。つまり、人気は出たが、利益は出なかったケースだと思います。
|
|
||||||
|
|
||||||
したがって、収益化までの道のりもまた長いのではないか。大変なのかもしれない。そんなふうに思うのです。
|
|
||||||
|
|
||||||
これを短絡的な見通しで「面白ければ売れる」などと考えていると、当てが外れるかもしれない。そのへんはあまり期待しないほうがいいかも。
|
|
||||||
|
|
||||||
段階的にそれぞれの戦略を考えていくのが良いのではないかな。
|
|
||||||
|
|
||||||
`面白い -> 人気が出る -> 利益が出る`...その間にも高い壁がある。
|
|
||||||
|
|
||||||
## オリジナリティはどうやって出すのか
|
|
||||||
|
|
||||||
例えば、制約からです。
|
|
||||||
|
|
||||||
私は設計において、いくつかの決め事を作りました。例えば、以下のルールがあります。
|
|
||||||
|
|
||||||
1. 物理法則に反しない
|
|
||||||
|
|
||||||
ゲームで物を浮かせるのは簡単だ。しかし、この世界は現実の反映を目指している。したがって、すぐに物を浮かせたり、あるいはテレポートしたり、それをやってはいけない。そういったものには必ず、原理を説明できなければならない。特殊なアイテムが必要となる。このアイテムをアイの家に3つ置いてあるとしよう。この場合、その3つをマップに置くと、その世界にはもうない。使えない。そのようなルールだ。その世界の重要なアイテムはアイが持っていて、作っている。無限には湧いてこない。制限がある。これはatprotoに保存し、公開しておくのもおもしろいかもしれないな。こういったことがその世界の由来につながるのだと思う。
|
|
||||||
|
|
||||||
2. マップは一つ
|
|
||||||
|
|
||||||
マップは必ず一つの中で完結させること。世界は一つというルール。たくさんのマップを分けてはいけない。不便でも一つのマップの中だけで世界を作ろう。マップを分けてはいけない。宇宙も地上も一つにすること。シーンやムービーを作るときも同じ。違うマップでそれをやってはいけない。
|
|
||||||
|
|
||||||
## 今回のゲーム作りで意識したこと
|
|
||||||
|
|
||||||
- game system v0.4.4
|
|
||||||
|
|
||||||
1. レトロゲームの面白さを必要最小限で実装
|
|
||||||
2. オリジナリティを融合(このゲームのテーマである宇宙、そして物語)
|
|
||||||
3. すべての実装を各システムで動かす
|
|
||||||
|
|
@@ -1,107 +0,0 @@
|
|||||||
---
|
|
||||||
title: "plcにhandle changeを反映する"
|
|
||||||
slug: "plc"
|
|
||||||
date: "2025-09-05"
|
|
||||||
tags: ["atproto"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
いつまで経ってもbsky.teamのplcにhandle changeが反映されないので色々やってみました。
|
|
||||||
|
|
||||||
結論から言うと、`PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX`を使用し、`base58`のrotation-keyを作成後に、indigoにある`goat plc`を使用します。
|
|
||||||
|
|
||||||
1. `goat key generate --type secp256k1`で生成されたキーを分析
|
|
||||||
2. そのキーから正しいmulticodecプレフィックスを抽出
|
|
||||||
3. PDSのhex keyに同じプレフィックスを適用
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ go install github.com/bluesky-social/indigo/cmd/goat@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ goat account login -u syui.syui.ai -p $PASS --pds-host https://syu.is
|
|
||||||
|
|
||||||
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
|
||||||
did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ
|
|
||||||
|
|
||||||
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
|
|
||||||
--handle syui.syui.ai \
|
|
||||||
--pds https://syu.is \
|
|
||||||
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ > plc_operation_syui.json
|
|
||||||
|
|
||||||
# もしミスった時は前の操作を無効化して再実行
|
|
||||||
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
|
|
||||||
--handle syui.syui.ai \
|
|
||||||
--pds https://syu.is \
|
|
||||||
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ \
|
|
||||||
--prev "bafyreifomvmymylntowv2mbyvg5i7wgv375757l574gevcs7qbysbqizk4" > plc_operation_syui_nullify.json
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
source base58_env/bin/activate
|
|
||||||
|
|
||||||
python3 -c "
|
|
||||||
import base58
|
|
||||||
|
|
||||||
# 生成されたsecp256k1キーを分析
|
|
||||||
generated_secp256k1 = '${zXXX...}'
|
|
||||||
decoded = base58.b58decode(generated_secp256k1[1:]) # 'z'を除く
|
|
||||||
|
|
||||||
print('Generated secp256k1 key analysis:')
|
|
||||||
print(' Total length:', len(decoded))
|
|
||||||
print(' Full hex:', decoded.hex())
|
|
||||||
|
|
||||||
# 32バイトの鍵データを除いたプレフィックスを抽出
|
|
||||||
if len(decoded) > 32:
|
|
||||||
prefix = decoded[:-32]
|
|
||||||
key_data = decoded[-32:]
|
|
||||||
print(' Prefix hex:', prefix.hex())
|
|
||||||
print(' Prefix length:', len(prefix))
|
|
||||||
print(' Key data length:', len(key_data))
|
|
||||||
|
|
||||||
pds_rotation_hex = '${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}'
|
|
||||||
pds_rotation_bytes = bytes.fromhex(pds_rotation_hex)
|
|
||||||
|
|
||||||
prefixed_rotation_key = prefix + pds_rotation_bytes
|
|
||||||
multibase_rotation_key = 'z' + base58.b58encode(prefixed_rotation_key).decode()
|
|
||||||
|
|
||||||
print('\\nConverted PDS rotation key:')
|
|
||||||
print(' Multibase:', multibase_rotation_key)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(' No prefix found, key is raw')
|
|
||||||
"
|
|
||||||
|
|
||||||
deactivate
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ PDS_ROTATION_KEY=zXXX...
|
|
||||||
|
|
||||||
$ goat plc sign --plc-signing-key "$PDS_ROTATION_KEY" plc_operation_syui.json > plc_signed_syui.json
|
|
||||||
$ goat plc submit --did did:plc:vzsvtbtbnwn22xjqhcu3vd6y plc_signed_syui.json
|
|
||||||
success
|
|
||||||
|
|
||||||
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
|
||||||
```
|
|
||||||
|
|
||||||
## 手順をおさらい
|
|
||||||
|
|
||||||
1. `plc_operation.json`を作成
|
|
||||||
2. `plc_operation.json`と`PDS_ROTATION_KEY`を使用し、`plc_signed.json`を作成
|
|
||||||
3. `plc_signed.json`を使用し、plcを更新
|
|
||||||
|
|
||||||
## plcを確認
|
|
||||||
|
|
||||||
```sh
|
|
||||||
did=did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
|
||||||
curl -sL "https://plc.directory/$did"|jq .alsoKnownAs
|
|
||||||
curl -sL "https://plc.syu.is/$did"|jq .alsoKnownAs
|
|
||||||
[
|
|
||||||
"at://syui.syui.ai"
|
|
||||||
]
|
|
||||||
[
|
|
||||||
"at://syui.syui.ai"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
title: "ue5でdualsenseを使う"
|
|
||||||
slug: "ps5-controller"
|
|
||||||
date: "2025-09-07"
|
|
||||||
tags: ["ue"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
ps5-controllerは`dualsense`というらしい。ue5で使うには、以下のpluginを使います。fabかgithubのreleaseからpluginフォルダに入れてbuildするか2つの方法があります。
|
|
||||||
|
|
||||||
## dualsense plugin
|
|
||||||
|
|
||||||
- [https://github.com/rafaelvaloto/WindowsDualsenseUnreal](https://github.com/rafaelvaloto/WindowsDualsenseUnreal)
|
|
||||||
- [https://github.com/rafaelvaloto/GamepadCoOp](https://github.com/rafaelvaloto/GamepadCoOp)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
`v1.2.10`からmultiplayを意識した`GamepadCoOp`との統合が行われました。
|
|
||||||
|
|
||||||
コントローラーのライトをキャラクター切り替え時に変更する処理を入れました。
|
|
||||||
|
|
||||||
<iframe src="https://blueprintue.com/render/tx_q1evf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
|
||||||
|
|
||||||
## dualsenseの分解
|
|
||||||
|
|
||||||
最近、ドリフト問題が発生していたこともあり、何度も分解していました。
|
|
||||||
|
|
||||||
よって、このタイプのコントローラーなら簡単に修理できるようになりました。
|
|
||||||
|
|
||||||
今後も`dualsense`を使用していく可能性は高いですね。
|
|
||||||
|
|
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
title: "comfyuiでwan2.2を試す"
|
|
||||||
slug: "comfyui"
|
|
||||||
date: "2025-09-10"
|
|
||||||
tags: ["comfyui"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
comfyuiにwan2.2が来ていたので試してみました。wanがcomfyuiの公式に採用されているので、導入が簡単になっています。
|
|
||||||
|
|
||||||
|
|
||||||
今回は爆速になったLoRA採用でいきます。なお、無効化ノードを外すとクオリティ重視の設定になります。
|
|
||||||
|
|
||||||
関係ありませんが、comfyui公式ページのコメントシステムは[giscus/giscus](https://github.com/giscus/giscus)を使用しているようですね。
|
|
||||||
|
|
||||||
# comfyui
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ git clone https://github.com/comfyanonymous/ComfyUI
|
|
||||||
$ cd ComfyUI
|
|
||||||
$ winget install python.python.3.13
|
|
||||||
$ pip uninstall torch torchaudio
|
|
||||||
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
もしvenvを使用する場合
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ python -m venv venv
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## wan2.2
|
|
||||||
|
|
||||||
基本的にpromptから生成し、bypassを切ると画像を参照できます。
|
|
||||||
|
|
||||||
workflowをdownloadしてcomfyuiで開きます。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# ComfyUI/user/default/workflows/
|
|
||||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_5B_ti2v.json
|
|
||||||
```
|
|
||||||
|
|
||||||
必要なものは公式ページにリンクがあります。
|
|
||||||
|
|
||||||
[https://docs.comfy.org/tutorials/video/wan/wan2_2](https://docs.comfy.org/tutorials/video/wan/wan2_2)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ComfyUI/
|
|
||||||
├───📂 models/
|
|
||||||
│ ├───📂 diffusion_models/
|
|
||||||
│ │ └───wan2.2_ti2v_5B_fp16.safetensors
|
|
||||||
│ ├───📂 text_encoders/
|
|
||||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
|
||||||
│ └───📂 vae/
|
|
||||||
│ └── wan2.2_vae.safetensors
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<video src="/img/comfyui_wan22_0001.mp4" width="100%" controls></video>
|
|
||||||
|
|
||||||
## wan2-2-fun-control
|
|
||||||
|
|
||||||
これはポーズを動画から作成して動画を作ります。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_control.json
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ComfyUI/
|
|
||||||
├───📂 models/
|
|
||||||
│ ├───📂 diffusion_models/
|
|
||||||
│ │ ├─── wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors
|
|
||||||
│ │ └─── wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors
|
|
||||||
│ ├───📂 loras/
|
|
||||||
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
|
|
||||||
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
|
|
||||||
│ ├───📂 text_encoders/
|
|
||||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
|
||||||
│ └───📂 vae/
|
|
||||||
│ └── wan_2.1_vae.safetensors
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## wan2-2-fun-inp
|
|
||||||
|
|
||||||
これは画像から画像を参考にして動画を生成します。
|
|
||||||
|
|
||||||
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_inpaint.json
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ComfyUI/
|
|
||||||
├───📂 models/
|
|
||||||
│ ├───📂 diffusion_models/
|
|
||||||
│ │ ├─── wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors
|
|
||||||
│ │ └─── wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors
|
|
||||||
│ ├───📂 loras/
|
|
||||||
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
|
|
||||||
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
|
|
||||||
│ ├───📂 text_encoders/
|
|
||||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
|
||||||
│ └───📂 vae/
|
|
||||||
│ └── wan_2.1_vae.safetensors
|
|
||||||
```
|
|
||||||
|
|
||||||
## ゲームで動かしたほうがいい
|
|
||||||
|
|
||||||
今回、ゲームのスクショを使って動画を生成してみました。
|
|
||||||
|
|
||||||
しかし、ゲームで動かしたほうがよほど早く確実です。
|
|
||||||
|
|
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
title: "comfyuiでフィギュア化する"
|
|
||||||
slug: "comfyui"
|
|
||||||
date: "2025-09-11"
|
|
||||||
tags: ["comfyui"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
`gemini`でnano bananaというフィギュア化が流行っています。今回は、それを`comfyui`で再現してみようと思います。
|
|
||||||
|
|
||||||
# comfyui
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ git clone https://github.com/comfyanonymous/ComfyUI
|
|
||||||
$ cd ComfyUI
|
|
||||||
$ winget install python.python.3.13
|
|
||||||
$ pip uninstall torch torchaudio
|
|
||||||
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
もしvenvを使用する場合
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ python -m venv venv
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## flux-1-kontext-dev
|
|
||||||
|
|
||||||
[https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev](https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev)
|
|
||||||
|
|
||||||
基本的にcomfyuiで作成した画像にはworkflowが保存されています。ですから、画像があればpromptやbypassなども含めて生成情報がわかります。情報が削除されていない限りは再現することが可能です。
|
|
||||||
|
|
||||||
今回は、`flux-1-kontext-dev`を使用します。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/example_workflows/main/flux/kontext/dev/flux_1_kontext_dev_basic.png
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
📂 ComfyUI/
|
|
||||||
├── 📂 models/
|
|
||||||
│ ├── 📂 diffusion_models/
|
|
||||||
│ │ └── flux1-dev-kontext_fp8_scaled.safetensors
|
|
||||||
│ ├── 📂 vae/
|
|
||||||
│ │ └── ae.safetensor
|
|
||||||
│ └── 📂 text_encoders/
|
|
||||||
│ ├── clip_l.safetensors
|
|
||||||
│ └── t5xxl_fp16.safetensors or t5xxl_fp8_e4m3fn_scaled.safetensors
|
|
||||||
```
|
|
||||||
|
|
||||||
[msg content="prompt: Convert to collectible figure style: detailed sculpting, premium paint job, professional product photography, studio lighting, pristine condition, commercial quality, toy photography aesthetic"]
|
|
||||||
|
|
||||||
以下は`wan2.1`で生成した時の動画。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
できました。
|
|
@@ -1,110 +0,0 @@
|
|||||||
---
|
|
||||||
title: "blenderで作ったモデルを改良した"
|
|
||||||
slug: "ue-blender-model"
|
|
||||||
date: "2025-09-18"
|
|
||||||
tags: ["ue", "blender"]
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
|
|
||||||
blenderで作ったモデルは、ueで動かしてみると、なかなか思ったとおりに動かないことは多いです。原因も多種多様で、とても一言では言い表せない。
|
|
||||||
|
|
||||||
今まで気になっていたところは以下の2点でした。
|
|
||||||
|
|
||||||
1. 指がちゃんと動かない
|
|
||||||
2. 衣装のすり抜けが気になる
|
|
||||||
|
|
||||||
## 指を修正するにはueからblenderへ
|
|
||||||
|
|
||||||
blenderで作ったモデルは指がぎこちない動きで、複数の要因が関係しています。特に大きいのが手動で塗っていたウェイトペイント。
|
|
||||||
|
|
||||||
しかし、これを完璧に塗り、かつueで動作確認するのはよくありません。なぜなら、blenderとueで動きが異なるからです。それも全く異なるわけではなく微妙に合わないのです。
|
|
||||||
|
|
||||||
ということで、ueでまず指の動きがちゃんとできているモデルをblenderに持ってきて、手の部分を移植するというのが今回採用した方法です。
|
|
||||||
|
|
||||||
- o: `ue -> blender`
|
|
||||||
- x: `blender -> ue`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
動きを見るのは、vrm4uの`RTG_UEFN_${name}`を使用します。
|
|
||||||
|
|
||||||
blenderの操作方法です。ポイントだけ書いておきます。
|
|
||||||
|
|
||||||
1. modelを2つ読み込む。aとbとする。
|
|
||||||
2. bのboneとbody(object)でいらない部分を削除する。ここでは手の部分だけ残す。 key[x]
|
|
||||||
3. a, bで大体の位置を合わせる。 key[g, z]
|
|
||||||
4. bのboneを選択肢、aのboneを選択して統合する。 key[C-j]
|
|
||||||
5. サイドバーのアーマチュアのところをみて、手のボーンを腕のボーンにいれる(これはモデルによる)。特に手がオフセット維持で指についていることが重要。 key[C-p]
|
|
||||||
6. bのbody(object)を選択し、モディファイアからaのbodyにデータ転送する。データ転送では、頂点データ、頂点グループを選択。適用する。
|
|
||||||
7. bのbodyを選択し、aのbodyを選択して統合する。 key[C-j]
|
|
||||||
8. bodyを編集して、手と腕をつなげる。
|
|
||||||
|
|
||||||
あとは、vrm exportの際に指とかのボーンを自動で入れれば動くと思います。
|
|
||||||
|
|
||||||
私の場合は、スカートに必要なボーンを追加したりもしました。これはueでの動作を意識しましょう。
|
|
||||||
|
|
||||||
## スカートと足の動きを関連付ける
|
|
||||||
|
|
||||||
衣装は、`Spine`以下にあるワンピースなので、厳密にはスカートではありませんが、ここではスカートということにします。
|
|
||||||
|
|
||||||
このスカートは、3d-modelでは非常に厄介なもので、足の動きに追従できず体に入り込んでしまうのです。
|
|
||||||
|
|
||||||
これを解消するためには様々な方法があり、たとえblenderの機能を使って解消しても、ueでは効果がありません。よって、こちらもueから解消するのがベストです。
|
|
||||||
|
|
||||||
今回、ABPに`Look At`を使うことで解消しました。
|
|
||||||
|
|
||||||
```md
|
|
||||||
# ABP
|
|
||||||
## Look At
|
|
||||||
- Bone to Modify: スカート前、中央
|
|
||||||
- Look at Target: Spine (中心)
|
|
||||||
|
|
||||||
## Look At
|
|
||||||
- Bone to Modify: スカート前、左
|
|
||||||
- Look at Target: LeftLeg (左足)
|
|
||||||
|
|
||||||
## Look At
|
|
||||||
- Bone to Modify: スカート前、右
|
|
||||||
- Look at Target: RightLeg (右足)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Look at Location`の位置は調整してください。私の場合は`0, 50, 0`です。
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3o98Aivn--0?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
完璧ではないけど、これでもかなり改良されたほう。
|
|
||||||
|
|
||||||
## 実践投入
|
|
||||||
|
|
||||||
### unique skillのデザインを考える
|
|
||||||
|
|
||||||
まず、アイのunique skill(ユニークスキル)のデザインを考えました。
|
|
||||||
|
|
||||||
1. カメラワークは正面に移動
|
|
||||||
2. スロー再生を開始
|
|
||||||
3. 忍術のようなモーション
|
|
||||||
4. カメラを通常に戻す
|
|
||||||
5. 属性の範囲ダメージ
|
|
||||||
|
|
||||||
### tatoolsを使って忍術モーションを作る
|
|
||||||
|
|
||||||
[tatools](https://www.fab.com/ja/listings/a5d3b60d-b886-4564-bf6d-15d46a8d27fe)を使います。
|
|
||||||
|
|
||||||
[https://github.com/threepeatgames/ThreepeatAnimTools](https://github.com/threepeatgames/ThreepeatAnimTools)
|
|
||||||
|
|
||||||
使い方は簡単ですが、動画が分かりづらいので、ポイントだけ解説します。pluginの起動、既存のアニメーションの修正、保存です。
|
|
||||||
|
|
||||||
1. pluginの起動は、`/Engine/Plugins/ThreepeatAnimTools/Picker/ThreepeatAnimTools_CR_Picker`を起動します。アウトライナーにでもウィンドウを追加しましょう。
|
|
||||||
2. 修正したいアニメーション(アニメシーケンス)を開いて、`シーケンサで編集 -> コントロールリグにベイク -> CR_UEFNMannyTatoolsRig`します。
|
|
||||||
3. これでlevel(map)上でレベルシーケンスを開けます。
|
|
||||||
4. ここからが修正ですが、まず、例えば、腕を選択して向きを変えたとしましょう。これだけでは保存されません。もとに戻ってしまいます。ここで、(1)シーケンサの下にあるアニメーションを削除し、(2)選択している部位のすべてのコンマを削除します。再生してみると編集したとおりになります。
|
|
||||||
5. 保存は、シーケンサのメニューバーにある保存ボタン(現在のシーケンスとサブシーケンスを保存)を押します。もとのアニメーションを開くと反映されています。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 実戦動画
|
|
||||||
|
|
||||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/tJQ1y-8p1hQ?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|
||||||
|
|
@@ -1,61 +0,0 @@
|
|||||||
<!-- AT Browser Integration - Temporarily disabled to fix site display -->
|
|
||||||
<!--
|
|
||||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="/assets/pds-browser.umd.js"></script>
|
|
||||||
<script>
|
|
||||||
// AT Browser integration - needs debugging
|
|
||||||
console.log('AT Browser integration temporarily disabled');
|
|
||||||
</script>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* AT Browser Modal Styles */
|
|
||||||
.at-uri-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-content {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 800px;
|
|
||||||
max-height: 600px;
|
|
||||||
width: 90%;
|
|
||||||
height: 80%;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1001;
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AT URI Link Styles */
|
|
||||||
[data-at-uri] {
|
|
||||||
color: #1976d2;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-at-uri]:hover {
|
|
||||||
color: #1565c0;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -1,152 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ config.language }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}{{ config.title }}{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
|
|
||||||
<!-- Stylesheets -->
|
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
|
||||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
|
||||||
<link rel="stylesheet" href="/css/pds.css">
|
|
||||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
|
||||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
|
||||||
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header class="main-header">
|
|
||||||
<div class="header-content">
|
|
||||||
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
|
|
||||||
<div class="logo">
|
|
||||||
<a href="/">
|
|
||||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
|
|
||||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
|
||||||
<g class="particleLayer">
|
|
||||||
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
|
|
||||||
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
|
|
||||||
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
|
|
||||||
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
|
|
||||||
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
|
|
||||||
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
|
|
||||||
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
|
|
||||||
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
|
|
||||||
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
|
|
||||||
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
|
|
||||||
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
|
|
||||||
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
|
|
||||||
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
|
|
||||||
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
|
||||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<!-- User Handle Input Form -->
|
|
||||||
<div class="pds-search-section">
|
|
||||||
<form class="pds-search-form" onsubmit="searchUser(); return false;">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="handleInput" placeholder="at://syui.ai" value="syui.ai" />
|
|
||||||
<button type="submit" id="searchButton" class="pds-btn">
|
|
||||||
@
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
|
||||||
<span class="ai-icon icon-ai"></span>
|
|
||||||
ai
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Ask AI Panel -->
|
|
||||||
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
|
||||||
<div class="ask-ai-content">
|
|
||||||
<div id="authCheck" class="auth-check">
|
|
||||||
<div class="loading-content">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
|
||||||
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
|
|
||||||
<button onclick="askQuestion()" id="askButton">Ask</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chatHistory" class="chat-history" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<!-- Pds Panel -->
|
|
||||||
{% include "pds-header.html" %}
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{% block sidebar %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="main-footer">
|
|
||||||
<div class="footer-social">
|
|
||||||
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
|
|
||||||
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
|
|
||||||
<a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
|
|
||||||
</div>
|
|
||||||
<p>© {{ config.author }}</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Config variables from Hugo
|
|
||||||
window.OAUTH_CONFIG = {
|
|
||||||
{% if config.oauth.pds %}
|
|
||||||
pds: "{{ config.oauth.pds }}",
|
|
||||||
{% else %}
|
|
||||||
pds: "syu.is",
|
|
||||||
{% endif %}
|
|
||||||
{% if config.oauth.admin %}
|
|
||||||
admin: "{{ config.oauth.admin }}",
|
|
||||||
{% else %}
|
|
||||||
admin: "ai.syui.ai",
|
|
||||||
{% endif %}
|
|
||||||
{% if config.oauth.collection %}
|
|
||||||
collection: "{{ config.oauth.collection }}"
|
|
||||||
{% else %}
|
|
||||||
collection: "ai.syui.log"
|
|
||||||
{% endif %}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="/js/ask-ai.js"></script>
|
|
||||||
<script src="/js/pds.js"></script>
|
|
||||||
<script src="/js/theme.js"></script>
|
|
||||||
<script src="/js/image-comparison.js"></script>
|
|
||||||
|
|
||||||
<!-- Mermaid support -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
|
|
||||||
<script>
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: true,
|
|
||||||
theme: 'neutral',
|
|
||||||
securityLevel: 'loose',
|
|
||||||
themeVariables: {
|
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
||||||
fontSize: '14px'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% include "oauth-assets.html" %}
|
|
||||||
{% include "at-browser-assets.html" %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@@ -1,135 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Game - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div id="gameContainer" class="game-container">
|
|
||||||
<div id="gameAuth" class="game-auth-section">
|
|
||||||
<h1>Login to Play</h1>
|
|
||||||
<p>Please authenticate with your AT Protocol account to access the game.</p>
|
|
||||||
<div id="authRoot"></div>
|
|
||||||
</div>
|
|
||||||
<div id="gameFrame" class="game-frame-container" style="display: none;">
|
|
||||||
<iframe
|
|
||||||
id="pixelStreamingFrame"
|
|
||||||
src="https://verse.syui.ai/simple-noui.html"
|
|
||||||
frameborder="0"
|
|
||||||
allowfullscreen
|
|
||||||
allow="microphone; camera; fullscreen; autoplay"
|
|
||||||
class="pixel-streaming-iframe"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Game specific styles */
|
|
||||||
.game-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background: #000;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section h1 {
|
|
||||||
font-size: 2.5em;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section p {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-frame-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pixel-streaming-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override auth button for game page */
|
|
||||||
.game-auth-section .auth-section {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section .auth-button {
|
|
||||||
font-size: 1.2em;
|
|
||||||
padding: 12px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide header and footer on game page */
|
|
||||||
body:has(.game-container) header,
|
|
||||||
body:has(.game-container) footer,
|
|
||||||
body:has(.game-container) nav {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove any body padding/margin for full screen game */
|
|
||||||
body:has(.game-container) {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Wait for OAuth component to be loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check if user is already authenticated
|
|
||||||
const checkAuthStatus = () => {
|
|
||||||
// Check if OAuth components are available and user is authenticated
|
|
||||||
if (window.currentUser && window.currentAgent) {
|
|
||||||
showGame();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show game iframe
|
|
||||||
const showGame = () => {
|
|
||||||
document.getElementById('gameAuth').style.display = 'none';
|
|
||||||
document.getElementById('gameFrame').style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for OAuth success
|
|
||||||
window.addEventListener('oauth-success', function(event) {
|
|
||||||
console.log('OAuth success:', event.detail);
|
|
||||||
showGame();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check auth status on load
|
|
||||||
if (!checkAuthStatus()) {
|
|
||||||
// Check periodically if OAuth components are loaded
|
|
||||||
const authCheckInterval = setInterval(() => {
|
|
||||||
if (checkAuthStatus()) {
|
|
||||||
clearInterval(authCheckInterval);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Include OAuth assets -->
|
|
||||||
{% include "oauth-assets.html" %}
|
|
||||||
{% endblock %}
|
|
@@ -1,45 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="timeline-container">
|
|
||||||
|
|
||||||
<div class="timeline-feed">
|
|
||||||
{% for post in posts %}
|
|
||||||
<article class="timeline-post">
|
|
||||||
<div class="post-header">
|
|
||||||
<div class="post-meta">
|
|
||||||
<time class="post-date">{{ post.date }}</time>
|
|
||||||
{% if post.language %}
|
|
||||||
<span class="post-lang">{{ post.language }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if post.type == "ai" %}
|
|
||||||
<span class="post-ai">
|
|
||||||
<span class="ai-icon icon-ai"></span>
|
|
||||||
ai
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="post-content">
|
|
||||||
<h3 class="post-title">
|
|
||||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OAuth Comment System -->
|
|
||||||
<section class="comment-section">
|
|
||||||
<div id="comment-atproto"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if posts|length == 0 %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No posts yet. Start writing!</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@@ -1,3 +0,0 @@
|
|||||||
<!-- OAuth Comment System - Load globally for session management -->
|
|
||||||
<script type="module" crossorigin src="/assets/comment-atproto-93YR1Hl3.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-rDV6HevJ.css">
|
|
@@ -1,71 +0,0 @@
|
|||||||
<!-- OAuth authentication widget for ailog -->
|
|
||||||
<div id="oauth-widget">
|
|
||||||
<div id="status" class="status">
|
|
||||||
Login with your Bluesky account
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login form -->
|
|
||||||
<div id="login-form">
|
|
||||||
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
<br>
|
|
||||||
<button id="login-btn">🦋 Login with Bluesky</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Authenticated state -->
|
|
||||||
<div id="authenticated-state" style="display: none;">
|
|
||||||
<div id="user-info"></div>
|
|
||||||
<button id="logout-btn">Logout</button>
|
|
||||||
<button id="test-profile-btn">Get Profile</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="console-log" class="log"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/oauth-widget-simple.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.status {
|
|
||||||
margin: 20px 0;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.user-info {
|
|
||||||
background: #e8f5e8;
|
|
||||||
border: 1px solid #4caf50;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: #ffeaea;
|
|
||||||
border: 1px solid #f44336;
|
|
||||||
color: #d32f2f;
|
|
||||||
}
|
|
||||||
#oauth-widget button {
|
|
||||||
background: #1185fe;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
#oauth-widget button:hover {
|
|
||||||
background: #0d6efd;
|
|
||||||
}
|
|
||||||
#oauth-widget button:disabled {
|
|
||||||
background: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.log {
|
|
||||||
text-align: left;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -1,48 +0,0 @@
|
|||||||
<div class="pds-container">
|
|
||||||
<div class="pds-header">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Current User DID -->
|
|
||||||
<div id="userDidSection" class="user-did-section" style="display: none;">
|
|
||||||
<div class="pds-display">
|
|
||||||
<strong>PDS:</strong> <span id="userPdsText"></span>
|
|
||||||
</div>
|
|
||||||
<div class="handle-display">
|
|
||||||
<strong>Handle:</strong> <span id="userHandleText"></span>
|
|
||||||
</div>
|
|
||||||
<div class="did-display">
|
|
||||||
<span id="userDidText"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collection List -->
|
|
||||||
<div id="collectionsSection" class="collections-section" style="display: none;">
|
|
||||||
<div class="collections-header">
|
|
||||||
<button id="collectionsToggle" class="collections-toggle" onclick="toggleCollections()">[+] Collections</button>
|
|
||||||
</div>
|
|
||||||
<div id="collectionsList" class="collections-list" style="display: none;">
|
|
||||||
<!-- Collections will be populated here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AT URI Records -->
|
|
||||||
<div id="recordsSection" class="records-section" style="display: none;">
|
|
||||||
<div id="recordsList" class="records-list">
|
|
||||||
<!-- Records will be populated here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AT URI Modal -->
|
|
||||||
<div id="atUriModal" class="at-uri-modal-overlay" style="display: none;" onclick="closeAtUriModal(event)">
|
|
||||||
<div class="at-uri-modal-content">
|
|
||||||
<button class="at-uri-modal-close" onclick="closeAtUriModal()">×</button>
|
|
||||||
<div id="atUriContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
@@ -1,6 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
@@ -1,373 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="article-container">
|
|
||||||
<article class="article-content">
|
|
||||||
<header class="article-header">
|
|
||||||
<h1 class="article-title">{{ post.title }}</h1>
|
|
||||||
<div class="article-meta">
|
|
||||||
<time class="article-date">{{ post.date }}</time>
|
|
||||||
{% if post.language %}
|
|
||||||
<span class="article-lang">{{ post.language }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="article-actions">
|
|
||||||
{% if post.markdown_url %}
|
|
||||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
|
||||||
.md
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if post.translation_url %}
|
|
||||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
|
||||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="article-body">
|
|
||||||
{{ post.content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comment Section -->
|
|
||||||
<section class="comment-section">
|
|
||||||
<div class="comment-container">
|
|
||||||
<h3>Comments</h3>
|
|
||||||
|
|
||||||
<!-- ATProto Auth Widget Container -->
|
|
||||||
<div id="atproto-auth-widget" class="comment-auth"></div>
|
|
||||||
|
|
||||||
<div id="commentForm" class="comment-form" style="display: none;">
|
|
||||||
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
|
|
||||||
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="commentsList" class="comments-list">
|
|
||||||
<!-- Comments will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="article-sidebar">
|
|
||||||
<nav class="toc">
|
|
||||||
<h3>Contents</h3>
|
|
||||||
<div id="toc-content">
|
|
||||||
<!-- TOC will be generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block sidebar %}
|
|
||||||
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
|
|
||||||
|
|
||||||
<!-- Fallback: Try multiple CDNs -->
|
|
||||||
<script>
|
|
||||||
console.log('Checking ATProto library availability...');
|
|
||||||
|
|
||||||
// Check if libraries loaded successfully
|
|
||||||
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
|
|
||||||
console.log('Primary CDN failed, trying fallback...');
|
|
||||||
|
|
||||||
// Create fallback script elements
|
|
||||||
const fallbackScripts = [
|
|
||||||
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
|
|
||||||
'https://esm.sh/@atproto/oauth-client-browser',
|
|
||||||
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Load fallback scripts sequentially
|
|
||||||
let scriptIndex = 0;
|
|
||||||
function loadNextScript() {
|
|
||||||
if (scriptIndex < fallbackScripts.length) {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = fallbackScripts[scriptIndex];
|
|
||||||
script.onload = () => {
|
|
||||||
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
|
|
||||||
window.atprotoLibrariesReady = true;
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
|
|
||||||
scriptIndex++;
|
|
||||||
loadNextScript();
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
} else {
|
|
||||||
console.error('All CDN fallbacks failed');
|
|
||||||
window.atprotoLibrariesReady = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNextScript();
|
|
||||||
} else {
|
|
||||||
console.log('✅ ATProto libraries loaded from primary CDN');
|
|
||||||
window.atprotoLibrariesReady = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Simple ATProto Widget (no external dependency) -->
|
|
||||||
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Initialize auth widget
|
|
||||||
let authWidget = null;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
generateTableOfContents();
|
|
||||||
initializeAuthWidget();
|
|
||||||
loadComments();
|
|
||||||
});
|
|
||||||
|
|
||||||
function generateTableOfContents() {
|
|
||||||
const tocContainer = document.getElementById('toc-content');
|
|
||||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
|
||||||
|
|
||||||
if (headings.length === 0) {
|
|
||||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tocList = document.createElement('ul');
|
|
||||||
tocList.className = 'toc-list';
|
|
||||||
|
|
||||||
headings.forEach((heading, index) => {
|
|
||||||
const id = `heading-${index}`;
|
|
||||||
heading.id = id;
|
|
||||||
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = `#${id}`;
|
|
||||||
link.textContent = heading.textContent;
|
|
||||||
link.className = 'toc-link';
|
|
||||||
|
|
||||||
// Smooth scroll behavior
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
heading.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
listItem.appendChild(link);
|
|
||||||
tocList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
tocContainer.appendChild(tocList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize ATProto Auth Widget
|
|
||||||
async function initializeAuthWidget() {
|
|
||||||
try {
|
|
||||||
// Check WebCrypto API availability
|
|
||||||
console.log('WebCrypto check:', {
|
|
||||||
available: !!window.crypto && !!window.crypto.subtle,
|
|
||||||
secureContext: window.isSecureContext,
|
|
||||||
protocol: window.location.protocol,
|
|
||||||
hostname: window.location.hostname
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!window.crypto || !window.crypto.subtle) {
|
|
||||||
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.isSecureContext) {
|
|
||||||
console.warn('Not in secure context - WebCrypto may not work properly');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified approach: Show manual OAuth form
|
|
||||||
console.log('Using simplified OAuth approach...');
|
|
||||||
showSimpleOAuthForm();
|
|
||||||
// Fallback to widget initialization
|
|
||||||
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
|
|
||||||
clientId: clientId,
|
|
||||||
onLogin: (session) => {
|
|
||||||
console.log('User logged in:', session.handle);
|
|
||||||
document.getElementById('commentForm').style.display = 'block';
|
|
||||||
},
|
|
||||||
onLogout: () => {
|
|
||||||
console.log('User logged out');
|
|
||||||
document.getElementById('commentForm').style.display = 'none';
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('ATProto Auth Error:', error);
|
|
||||||
// Show user-friendly error message
|
|
||||||
const authContainer = document.getElementById('atproto-auth-widget');
|
|
||||||
if (authContainer) {
|
|
||||||
let errorMessage = 'Authentication service is temporarily unavailable.';
|
|
||||||
let suggestion = 'Please try refreshing the page.';
|
|
||||||
|
|
||||||
if (error.message && error.message.includes('WebCrypto')) {
|
|
||||||
errorMessage = 'This feature requires a secure HTTPS connection.';
|
|
||||||
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
|
|
||||||
}
|
|
||||||
|
|
||||||
authContainer.innerHTML = `
|
|
||||||
<div class="atproto-auth__fallback">
|
|
||||||
<p>${errorMessage}</p>
|
|
||||||
<p>${suggestion}</p>
|
|
||||||
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
|
|
||||||
<summary>Technical details</summary>
|
|
||||||
<pre>${error.message || 'Unknown error'}</pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
theme: 'default'
|
|
||||||
});
|
|
||||||
} else if (typeof window.ATProtoAuthWidget === 'function') {
|
|
||||||
// Fallback to direct widget initialization
|
|
||||||
authWidget = new window.ATProtoAuthWidget({
|
|
||||||
containerSelector: '#atproto-auth-widget',
|
|
||||||
clientId: clientId,
|
|
||||||
onLogin: (session) => {
|
|
||||||
console.log('User logged in:', session.handle);
|
|
||||||
document.getElementById('commentForm').style.display = 'block';
|
|
||||||
},
|
|
||||||
onLogout: () => {
|
|
||||||
console.log('User logged out');
|
|
||||||
document.getElementById('commentForm').style.display = 'none';
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('ATProto Auth Error:', error);
|
|
||||||
const authContainer = document.getElementById('atproto-auth-widget');
|
|
||||||
if (authContainer) {
|
|
||||||
authContainer.innerHTML = `
|
|
||||||
<div class="atproto-auth__fallback">
|
|
||||||
<p>Authentication service is temporarily unavailable.</p>
|
|
||||||
<p>Please try refreshing the page.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
theme: 'default'
|
|
||||||
});
|
|
||||||
await authWidget.init();
|
|
||||||
} else {
|
|
||||||
throw new Error('ATProto widget not available');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize auth widget:', error);
|
|
||||||
// Show fallback UI
|
|
||||||
const authContainer = document.getElementById('atproto-auth-widget');
|
|
||||||
if (authContainer) {
|
|
||||||
authContainer.innerHTML = `
|
|
||||||
<div class="atproto-auth__fallback">
|
|
||||||
<p>Authentication widget failed to load.</p>
|
|
||||||
<p>Please check your internet connection and refresh the page.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitComment() {
|
|
||||||
const commentText = document.getElementById('commentText').value.trim();
|
|
||||||
if (!commentText || !authWidget.isLoggedIn()) {
|
|
||||||
alert('Please login and enter a comment');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const postSlug = '{{ post.slug }}';
|
|
||||||
const postUrl = window.location.href;
|
|
||||||
const createdAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// Create comment record using the auth widget
|
|
||||||
const response = await authWidget.createRecord('ai.log.comment', {
|
|
||||||
$type: 'ai.log.comment',
|
|
||||||
text: commentText,
|
|
||||||
post_slug: postSlug,
|
|
||||||
post_url: postUrl,
|
|
||||||
createdAt: createdAt
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Comment posted:', response);
|
|
||||||
document.getElementById('commentText').value = '';
|
|
||||||
loadComments();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Comment submission failed:', error);
|
|
||||||
alert('Failed to post comment: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAuthenticatedState(session) {
|
|
||||||
const authContainer = document.getElementById('atproto-auth-widget');
|
|
||||||
const agent = new window.ATProtoAgent(session);
|
|
||||||
|
|
||||||
authContainer.innerHTML = `
|
|
||||||
<div class="atproto-auth__authenticated">
|
|
||||||
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
|
|
||||||
<button id="logout-btn" class="atproto-auth__button">Logout</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('logout-btn').onclick = async () => {
|
|
||||||
await session.signOut();
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show comment form
|
|
||||||
document.getElementById('commentForm').style.display = 'block';
|
|
||||||
window.currentSession = session;
|
|
||||||
window.currentAgent = agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoginForm(oauthClient) {
|
|
||||||
const authContainer = document.getElementById('atproto-auth-widget');
|
|
||||||
|
|
||||||
authContainer.innerHTML = `
|
|
||||||
<div class="atproto-auth__login">
|
|
||||||
<h4>Login with ATProto</h4>
|
|
||||||
<input type="text" id="handle-input" placeholder="user.bsky.social" />
|
|
||||||
<button id="login-btn" class="atproto-auth__button">Connect</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('login-btn').onclick = async () => {
|
|
||||||
const handle = document.getElementById('handle-input').value.trim();
|
|
||||||
if (!handle) {
|
|
||||||
alert('Please enter your handle');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = await oauthClient.authorize(handle);
|
|
||||||
window.open(url, '_self', 'noopener');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OAuth authorization failed:', error);
|
|
||||||
alert('Authentication failed: ' + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enter key support
|
|
||||||
document.getElementById('handle-input').onkeypress = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
document.getElementById('login-btn').click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadComments() {
|
|
||||||
try {
|
|
||||||
const commentsList = document.getElementById('commentsList');
|
|
||||||
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
|
|
||||||
|
|
||||||
// In a real implementation, you would query an aggregation service
|
|
||||||
// For demo, show empty state
|
|
||||||
setTimeout(() => {
|
|
||||||
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load comments:', error);
|
|
||||||
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
@@ -1,196 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="article-container">
|
|
||||||
<article class="article-content">
|
|
||||||
<header class="article-header">
|
|
||||||
<h1 class="article-title">{{ post.title }}</h1>
|
|
||||||
<div class="article-meta">
|
|
||||||
<time class="article-date">{{ post.date }}</time>
|
|
||||||
{% if post.language %}
|
|
||||||
<span class="article-lang">{{ post.language }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="article-actions">
|
|
||||||
{% if post.markdown_url %}
|
|
||||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
|
||||||
.md
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if post.translation_url %}
|
|
||||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
|
||||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="article-body">
|
|
||||||
{{ post.content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Simple Comment Section -->
|
|
||||||
<section class="comment-section">
|
|
||||||
<div class="comment-container">
|
|
||||||
<h3>Comments</h3>
|
|
||||||
|
|
||||||
<!-- Simple OAuth Button -->
|
|
||||||
<div class="simple-oauth">
|
|
||||||
<p>📝 To comment, authenticate with Bluesky:</p>
|
|
||||||
<button id="bluesky-auth" class="oauth-button">
|
|
||||||
🦋 Login with Bluesky
|
|
||||||
</button>
|
|
||||||
<p class="oauth-note">
|
|
||||||
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="comments-list" class="comments-list">
|
|
||||||
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="article-sidebar">
|
|
||||||
<nav class="toc">
|
|
||||||
<h3>Contents</h3>
|
|
||||||
<div id="toc-content">
|
|
||||||
<!-- TOC will be generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block sidebar %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
generateTableOfContents();
|
|
||||||
initializeSimpleAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
function generateTableOfContents() {
|
|
||||||
const tocContainer = document.getElementById('toc-content');
|
|
||||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
|
||||||
|
|
||||||
if (headings.length === 0) {
|
|
||||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tocList = document.createElement('ul');
|
|
||||||
tocList.className = 'toc-list';
|
|
||||||
|
|
||||||
headings.forEach((heading, index) => {
|
|
||||||
const id = `heading-${index}`;
|
|
||||||
heading.id = id;
|
|
||||||
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = `#${id}`;
|
|
||||||
link.textContent = heading.textContent;
|
|
||||||
link.className = 'toc-link';
|
|
||||||
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
heading.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
listItem.appendChild(link);
|
|
||||||
tocList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
tocContainer.appendChild(tocList);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeSimpleAuth() {
|
|
||||||
const authButton = document.getElementById('bluesky-auth');
|
|
||||||
|
|
||||||
authButton.addEventListener('click', function() {
|
|
||||||
// Simple approach: Direct redirect to Bluesky OAuth
|
|
||||||
const isProduction = window.location.hostname === 'log.syui.ai';
|
|
||||||
const clientId = isProduction
|
|
||||||
? 'https://log.syui.ai/client-metadata.json'
|
|
||||||
: window.location.origin + '/client-metadata.json';
|
|
||||||
|
|
||||||
const authUrl = `https://bsky.social/oauth/authorize?` +
|
|
||||||
`client_id=${encodeURIComponent(clientId)}&` +
|
|
||||||
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
|
|
||||||
`response_type=code&` +
|
|
||||||
`scope=atproto%20transition:generic&` +
|
|
||||||
`state=demo-state`;
|
|
||||||
|
|
||||||
console.log('Redirecting to:', authUrl);
|
|
||||||
|
|
||||||
// Open in new tab for now (safer for testing)
|
|
||||||
window.open(authUrl, '_blank');
|
|
||||||
|
|
||||||
// Show status message
|
|
||||||
authButton.innerHTML = '✅ Check the new tab for authentication';
|
|
||||||
authButton.disabled = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we're returning from OAuth
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
if (urlParams.has('code')) {
|
|
||||||
console.log('OAuth callback detected:', urlParams.get('code'));
|
|
||||||
document.querySelector('.simple-oauth').innerHTML = `
|
|
||||||
<div class="oauth-success">
|
|
||||||
✅ OAuth callback received!<br>
|
|
||||||
<small>Code: ${urlParams.get('code')}</small><br>
|
|
||||||
<small>In a full implementation, this would exchange the code for tokens.</small>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.simple-oauth {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button {
|
|
||||||
background: #1185fe;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button:hover {
|
|
||||||
background: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button:disabled {
|
|
||||||
background: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-note {
|
|
||||||
color: #6c757d;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-success {
|
|
||||||
background: #d1edff;
|
|
||||||
border: 1px solid #b6d7ff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
@@ -1,106 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="article-container">
|
|
||||||
<article class="article-content">
|
|
||||||
<header class="article-header">
|
|
||||||
<h1 class="article-title">{{ post.title }}</h1>
|
|
||||||
<div class="article-meta">
|
|
||||||
<time class="article-date">{{ post.date }}</time>
|
|
||||||
{% if post.language %}
|
|
||||||
<span class="article-lang">{{ post.language }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if post.extra.type == "ai" %}
|
|
||||||
<span class="article-ai">
|
|
||||||
<span class="ai-icon icon-ai"></span>
|
|
||||||
ai
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
|
||||||
<nav class="toc">
|
|
||||||
<h3>Contents</h3>
|
|
||||||
<div id="toc-content">
|
|
||||||
<!-- TOC will be generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="article-body">
|
|
||||||
{{ post.content | safe }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="comment-atproto"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Generate table of contents
|
|
||||||
function generateTableOfContents() {
|
|
||||||
const tocContainer = document.getElementById('toc-content');
|
|
||||||
if (!tocContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
|
|
||||||
|
|
||||||
if (headings.length === 0) {
|
|
||||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tocList = document.createElement('ul');
|
|
||||||
tocList.className = 'toc-list';
|
|
||||||
|
|
||||||
headings.forEach((heading, index) => {
|
|
||||||
const id = `heading-${index}`;
|
|
||||||
heading.id = id;
|
|
||||||
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = `#${id}`;
|
|
||||||
link.textContent = heading.textContent;
|
|
||||||
link.className = 'toc-link';
|
|
||||||
|
|
||||||
link.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
heading.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
listItem.appendChild(link);
|
|
||||||
tocList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
tocContainer.appendChild(tocList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
generateTableOfContents();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block sidebar %}
|
|
||||||
{% endblock %}
|
|
@@ -1,345 +0,0 @@
|
|||||||
@import url('./style.css');
|
|
||||||
|
|
||||||
.pds-container {
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-header h1 {
|
|
||||||
font-size: 2.5em;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-search-section {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-search-form {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px 0 0 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
width: 600px;
|
|
||||||
outline: none;
|
|
||||||
transition: box-shadow 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
border-color: var(--theme-color, #f40);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group button {
|
|
||||||
padding: 9px 15px;
|
|
||||||
background: #1976d2;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group button:hover {
|
|
||||||
background: #1565c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
.user-info {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.user-profile {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-details h3 {
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-details p {
|
|
||||||
margin: 0;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-did-section {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.did-display {
|
|
||||||
padding: 10px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
word-break: break-all;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-display {
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #f0f9f0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-display strong {
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-display span {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.pds-display {
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #e8f4f8;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-display strong {
|
|
||||||
color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-display span {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-section,
|
|
||||||
.records-section {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-section h3,
|
|
||||||
.records-section h3 {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-list,
|
|
||||||
.records-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-link {
|
|
||||||
display: block;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
color: #1976d2;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
word-break: break-all;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-link:hover {
|
|
||||||
background: #e8f4f8;
|
|
||||||
border-color: #1976d2;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-info {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #b3e5fc;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #1976d2;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-info {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f0f9f0;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #b3e5b3;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #2e7d32;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-header {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-toggle {
|
|
||||||
background: #f5f5f5;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collections-toggle:hover {
|
|
||||||
background: #e8f4f8;
|
|
||||||
border-color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.pds-test-section,
|
|
||||||
.pds-about-section {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-test-section h2,
|
|
||||||
.pds-about-section h2 {
|
|
||||||
font-size: 1.8em;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
border-bottom: 2px solid #1976d2;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-uris {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
word-break: break-all;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri:hover {
|
|
||||||
background: #e8f4f8;
|
|
||||||
border-color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-about-section ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pds-about-section li {
|
|
||||||
padding: 5px 0;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* AT URI Modal Styles */
|
|
||||||
.at-uri-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-content {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 800px;
|
|
||||||
max-height: 600px;
|
|
||||||
width: 90%;
|
|
||||||
height: 80%;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1001;
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading states */
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #d32f2f;
|
|
||||||
background: #ffeaea;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.pds-search-section {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.pds-search-form {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@@ -139,7 +139,7 @@ a.view-markdown:any-link {
|
|||||||
grid-area: header;
|
grid-area: header;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1px solid #d1d9e0;
|
border-bottom: 1px solid #d1d9e0;
|
||||||
padding: 17px 24px;
|
padding: 16px 24px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -723,7 +723,7 @@ article.article-content {
|
|||||||
.footer-social a {
|
.footer-social a {
|
||||||
color: var(--dark-gray) !important;
|
color: var(--dark-gray) !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
font-size: 25px;
|
font-size: 20px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,11 +951,9 @@ article.article-content {
|
|||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 15px !important;
|
margin-bottom: 15px !important;
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
.form-input, .form-textarea {
|
.form-input, .form-textarea {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -1840,17 +1838,3 @@ article.article-content {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
button.ask-at-btn {
|
|
||||||
margin: 10px;
|
|
||||||
background: var(--theme-color);
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ask-at-btn a {
|
|
||||||
color: var(--ai-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
button#searchButton.pds-btn {
|
|
||||||
background: var(--theme-color);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 256 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 520 KiB |
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 766 KiB |
Before Width: | Height: | Size: 888 KiB |
Before Width: | Height: | Size: 723 KiB |
Before Width: | Height: | Size: 2.2 MiB |
@@ -1,370 +0,0 @@
|
|||||||
// AT Protocol API functions
|
|
||||||
const AT_PROTOCOL_CONFIG = {
|
|
||||||
primary: {
|
|
||||||
pds: 'https://syu.is',
|
|
||||||
plc: 'https://plc.syu.is',
|
|
||||||
bsky: 'https://bsky.syu.is',
|
|
||||||
web: 'https://web.syu.is'
|
|
||||||
},
|
|
||||||
fallback: {
|
|
||||||
pds: 'https://bsky.social',
|
|
||||||
plc: 'https://plc.directory',
|
|
||||||
bsky: 'https://public.api.bsky.app',
|
|
||||||
web: 'https://bsky.app'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search user function
|
|
||||||
async function searchUser() {
|
|
||||||
const handleInput = document.getElementById('handleInput');
|
|
||||||
const userInfo = document.getElementById('userInfo');
|
|
||||||
const collectionsList = document.getElementById('collectionsList');
|
|
||||||
const recordsList = document.getElementById('recordsList');
|
|
||||||
const searchButton = document.getElementById('searchButton');
|
|
||||||
|
|
||||||
const input = handleInput.value.trim();
|
|
||||||
if (!input) {
|
|
||||||
alert('Handle nameまたはAT URIを入力してください');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchButton.disabled = true;
|
|
||||||
searchButton.innerHTML = '@';
|
|
||||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Clear previous results
|
|
||||||
document.getElementById('userDidSection').style.display = 'none';
|
|
||||||
document.getElementById('collectionsSection').style.display = 'none';
|
|
||||||
document.getElementById('recordsSection').style.display = 'none';
|
|
||||||
collectionsList.innerHTML = '';
|
|
||||||
recordsList.innerHTML = '';
|
|
||||||
|
|
||||||
// Check if input is AT URI
|
|
||||||
if (input.startsWith('at://')) {
|
|
||||||
// Parse AT URI to check if it's a full record or just a handle/collection
|
|
||||||
const uriParts = input.replace('at://', '').split('/').filter(part => part.length > 0);
|
|
||||||
|
|
||||||
if (uriParts.length >= 3) {
|
|
||||||
// Full AT URI with rkey - show in modal
|
|
||||||
showAtUriModal(input);
|
|
||||||
return;
|
|
||||||
} else if (uriParts.length === 1) {
|
|
||||||
// Just handle in AT URI format (at://handle) - treat as regular handle
|
|
||||||
const handle = uriParts[0];
|
|
||||||
const userProfile = await resolveUserProfile(handle);
|
|
||||||
|
|
||||||
if (userProfile.success) {
|
|
||||||
displayUserDid(userProfile.data);
|
|
||||||
await loadUserCollections(handle, userProfile.data.did);
|
|
||||||
} else {
|
|
||||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else if (uriParts.length === 2) {
|
|
||||||
// Collection level AT URI - load collection records
|
|
||||||
const [repo, collection] = uriParts;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First resolve the repo to get handle if it's a DID
|
|
||||||
let handle = repo;
|
|
||||||
if (repo.startsWith('did:')) {
|
|
||||||
// Try to resolve DID to handle - for now just use the DID
|
|
||||||
handle = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCollectionRecords(handle, collection, repo);
|
|
||||||
} catch (error) {
|
|
||||||
alert('コレクションの読み込みに失敗しました: ' + error.message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle regular handle search
|
|
||||||
const userProfile = await resolveUserProfile(input);
|
|
||||||
|
|
||||||
if (userProfile.success) {
|
|
||||||
displayUserDid(userProfile.data);
|
|
||||||
await loadUserCollections(input, userProfile.data.did);
|
|
||||||
} else {
|
|
||||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('エラーが発生しました: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
searchButton.disabled = false;
|
|
||||||
searchButton.innerHTML = '@';
|
|
||||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve user profile
|
|
||||||
async function resolveUserProfile(handle) {
|
|
||||||
try {
|
|
||||||
let response = null;
|
|
||||||
|
|
||||||
// Try syu.is first
|
|
||||||
try {
|
|
||||||
response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to resolve from syu.is:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If syu.is fails, try bsky.social
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to resolve handle');
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoData = await response.json();
|
|
||||||
|
|
||||||
// Get profile data
|
|
||||||
const profileResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.bsky}/xrpc/app.bsky.actor.getProfile?actor=${repoData.did}`);
|
|
||||||
const profileData = await profileResponse.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
did: repoData.did,
|
|
||||||
handle: profileData.handle,
|
|
||||||
displayName: profileData.displayName,
|
|
||||||
avatar: profileData.avatar,
|
|
||||||
description: profileData.description,
|
|
||||||
pds: repoData.didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display user DID
|
|
||||||
function displayUserDid(profile) {
|
|
||||||
document.getElementById('userPdsText').textContent = profile.pds || 'Unknown';
|
|
||||||
document.getElementById('userHandleText').textContent = profile.handle;
|
|
||||||
document.getElementById('userDidText').textContent = profile.did;
|
|
||||||
document.getElementById('userDidSection').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user collections
|
|
||||||
async function loadUserCollections(handle, did) {
|
|
||||||
const collectionsList = document.getElementById('collectionsList');
|
|
||||||
|
|
||||||
collectionsList.innerHTML = '<div class="loading">コレクションを読み込み中...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to get collections from describeRepo
|
|
||||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
|
||||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
|
||||||
|
|
||||||
// If syu.is fails, try bsky.social
|
|
||||||
if (!response.ok) {
|
|
||||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
|
||||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to describe repository');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const collections = data.collections || [];
|
|
||||||
|
|
||||||
// Display collections as AT URI links
|
|
||||||
collectionsList.innerHTML = '';
|
|
||||||
if (collections.length === 0) {
|
|
||||||
collectionsList.innerHTML = '<div class="error">コレクションが見つかりませんでした</div>';
|
|
||||||
} else {
|
|
||||||
|
|
||||||
collections.forEach(collection => {
|
|
||||||
const atUri = `at://${did}/${collection}/`;
|
|
||||||
const collectionElement = document.createElement('a');
|
|
||||||
collectionElement.className = 'at-uri-link';
|
|
||||||
collectionElement.href = '#';
|
|
||||||
collectionElement.textContent = atUri;
|
|
||||||
collectionElement.onclick = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
loadCollectionRecords(handle, collection, did);
|
|
||||||
// Close collections and update toggle
|
|
||||||
document.getElementById('collectionsList').style.display = 'none';
|
|
||||||
document.getElementById('collectionsToggle').textContent = '[-] Collections';
|
|
||||||
};
|
|
||||||
collectionsList.appendChild(collectionElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('collectionsSection').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
collectionsList.innerHTML = '<div class="error">コレクションの読み込みに失敗しました: ' + error.message + '</div>';
|
|
||||||
document.getElementById('collectionsSection').style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load collection records
|
|
||||||
async function loadCollectionRecords(handle, collection, did) {
|
|
||||||
const recordsList = document.getElementById('recordsList');
|
|
||||||
|
|
||||||
recordsList.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try with syu.is first
|
|
||||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
|
||||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
|
||||||
|
|
||||||
// If that fails, try with bsky.social
|
|
||||||
if (!response.ok) {
|
|
||||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
|
||||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load records');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Display records as AT URI links
|
|
||||||
recordsList.innerHTML = '';
|
|
||||||
|
|
||||||
// Add collection info for records
|
|
||||||
const collectionInfo = document.createElement('div');
|
|
||||||
collectionInfo.className = 'collection-info';
|
|
||||||
collectionInfo.innerHTML = `<strong>${collection}</strong>`;
|
|
||||||
recordsList.appendChild(collectionInfo);
|
|
||||||
|
|
||||||
data.records.forEach(record => {
|
|
||||||
const atUri = record.uri;
|
|
||||||
const recordElement = document.createElement('a');
|
|
||||||
recordElement.className = 'at-uri-link';
|
|
||||||
recordElement.href = '#';
|
|
||||||
recordElement.textContent = atUri;
|
|
||||||
recordElement.onclick = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showAtUriModal(atUri);
|
|
||||||
};
|
|
||||||
recordsList.appendChild(recordElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('recordsSection').style.display = 'block';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
recordsList.innerHTML = '<div class="error">レコードの読み込みに失敗しました: ' + error.message + '</div>';
|
|
||||||
document.getElementById('recordsSection').style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show AT URI modal
|
|
||||||
function showAtUriModal(uri) {
|
|
||||||
const modal = document.getElementById('atUriModal');
|
|
||||||
const content = document.getElementById('atUriContent');
|
|
||||||
|
|
||||||
content.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
|
|
||||||
// Load record data
|
|
||||||
loadAtUriRecord(uri, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load AT URI record
|
|
||||||
async function loadAtUriRecord(uri, contentElement) {
|
|
||||||
try {
|
|
||||||
const parts = uri.replace('at://', '').split('/');
|
|
||||||
const repo = parts[0];
|
|
||||||
const collection = parts[1];
|
|
||||||
const rkey = parts[2];
|
|
||||||
|
|
||||||
// Try with syu.is first
|
|
||||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
|
||||||
|
|
||||||
// If that fails, try with bsky.social
|
|
||||||
if (!response.ok) {
|
|
||||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load record');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
contentElement.innerHTML = `
|
|
||||||
<div style="padding: 20px;">
|
|
||||||
<h3>AT URI Record</h3>
|
|
||||||
<div style="font-family: monospace; font-size: 14px; color: #666; margin-bottom: 20px; word-break: break-all;">
|
|
||||||
${uri}
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 12px; color: #999; margin-bottom: 20px;">
|
|
||||||
Repo: ${repo} | Collection: ${collection} | RKey: ${rkey}
|
|
||||||
</div>
|
|
||||||
<h4>Record Data</h4>
|
|
||||||
<pre style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
contentElement.innerHTML = `
|
|
||||||
<div style="padding: 20px; color: red;">
|
|
||||||
<strong>Error:</strong> ${error.message}
|
|
||||||
<div style="margin-top: 10px; font-size: 12px;">
|
|
||||||
<strong>URI:</strong> ${uri}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close AT URI modal
|
|
||||||
function closeAtUriModal(event) {
|
|
||||||
const modal = document.getElementById('atUriModal');
|
|
||||||
if (event && event.target !== modal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize AT URI click handlers
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Add click handlers to existing AT URIs
|
|
||||||
document.querySelectorAll('.at-uri').forEach(element => {
|
|
||||||
element.addEventListener('click', function() {
|
|
||||||
const uri = this.getAttribute('data-at-uri');
|
|
||||||
showAtUriModal(uri);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ESC key to close modal
|
|
||||||
document.addEventListener('keydown', function(event) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeAtUriModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enter key to search
|
|
||||||
document.getElementById('handleInput').addEventListener('keydown', function(event) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
searchUser();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle collections visibility
|
|
||||||
function toggleCollections() {
|
|
||||||
const collectionsList = document.getElementById('collectionsList');
|
|
||||||
const toggleButton = document.getElementById('collectionsToggle');
|
|
||||||
|
|
||||||
if (collectionsList.style.display === 'none') {
|
|
||||||
collectionsList.style.display = 'block';
|
|
||||||
toggleButton.textContent = '[-] Collections';
|
|
||||||
} else {
|
|
||||||
collectionsList.style.display = 'none';
|
|
||||||
toggleButton.textContent = '[+] Collections';
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,61 +0,0 @@
|
|||||||
<!-- AT Browser Integration - Temporarily disabled to fix site display -->
|
|
||||||
<!--
|
|
||||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="/assets/pds-browser.umd.js"></script>
|
|
||||||
<script>
|
|
||||||
// AT Browser integration - needs debugging
|
|
||||||
console.log('AT Browser integration temporarily disabled');
|
|
||||||
</script>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* AT Browser Modal Styles */
|
|
||||||
.at-uri-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-content {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 800px;
|
|
||||||
max-height: 600px;
|
|
||||||
width: 90%;
|
|
||||||
height: 80%;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1001;
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AT URI Link Styles */
|
|
||||||
[data-at-uri] {
|
|
||||||
color: #1976d2;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-at-uri]:hover {
|
|
||||||
color: #1565c0;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -12,7 +12,6 @@
|
|||||||
<!-- Stylesheets -->
|
<!-- Stylesheets -->
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
||||||
<link rel="stylesheet" href="/css/pds.css">
|
|
||||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
||||||
|
|
||||||
@@ -50,17 +49,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<!-- User Handle Input Form -->
|
|
||||||
<div class="pds-search-section">
|
|
||||||
<form class="pds-search-form" onsubmit="searchUser(); return false;">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="handleInput" placeholder="at://syui.ai" value="syui.ai" />
|
|
||||||
<button type="submit" id="searchButton" class="pds-btn">
|
|
||||||
@
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
||||||
<span class="ai-icon icon-ai"></span>
|
<span class="ai-icon icon-ai"></span>
|
||||||
ai
|
ai
|
||||||
@@ -89,9 +77,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Pds Panel -->
|
|
||||||
{% include "pds-header.html" %}
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -128,7 +113,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/ask-ai.js"></script>
|
<script src="/js/ask-ai.js"></script>
|
||||||
<script src="/js/pds.js"></script>
|
|
||||||
<script src="/js/theme.js"></script>
|
<script src="/js/theme.js"></script>
|
||||||
<script src="/js/image-comparison.js"></script>
|
<script src="/js/image-comparison.js"></script>
|
||||||
|
|
||||||
@@ -147,6 +131,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% include "oauth-assets.html" %}
|
{% include "oauth-assets.html" %}
|
||||||
{% include "at-browser-assets.html" %}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -1,135 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Game - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div id="gameContainer" class="game-container">
|
|
||||||
<div id="gameAuth" class="game-auth-section">
|
|
||||||
<h1>Login to Play</h1>
|
|
||||||
<p>Please authenticate with your AT Protocol account to access the game.</p>
|
|
||||||
<div id="authRoot"></div>
|
|
||||||
</div>
|
|
||||||
<div id="gameFrame" class="game-frame-container" style="display: none;">
|
|
||||||
<iframe
|
|
||||||
id="pixelStreamingFrame"
|
|
||||||
src="https://verse.syui.ai/simple-noui.html"
|
|
||||||
frameborder="0"
|
|
||||||
allowfullscreen
|
|
||||||
allow="microphone; camera; fullscreen; autoplay"
|
|
||||||
class="pixel-streaming-iframe"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Game specific styles */
|
|
||||||
.game-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background: #000;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section h1 {
|
|
||||||
font-size: 2.5em;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section p {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-frame-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pixel-streaming-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override auth button for game page */
|
|
||||||
.game-auth-section .auth-section {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-auth-section .auth-button {
|
|
||||||
font-size: 1.2em;
|
|
||||||
padding: 12px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide header and footer on game page */
|
|
||||||
body:has(.game-container) header,
|
|
||||||
body:has(.game-container) footer,
|
|
||||||
body:has(.game-container) nav {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove any body padding/margin for full screen game */
|
|
||||||
body:has(.game-container) {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Wait for OAuth component to be loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check if user is already authenticated
|
|
||||||
const checkAuthStatus = () => {
|
|
||||||
// Check if OAuth components are available and user is authenticated
|
|
||||||
if (window.currentUser && window.currentAgent) {
|
|
||||||
showGame();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show game iframe
|
|
||||||
const showGame = () => {
|
|
||||||
document.getElementById('gameAuth').style.display = 'none';
|
|
||||||
document.getElementById('gameFrame').style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for OAuth success
|
|
||||||
window.addEventListener('oauth-success', function(event) {
|
|
||||||
console.log('OAuth success:', event.detail);
|
|
||||||
showGame();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check auth status on load
|
|
||||||
if (!checkAuthStatus()) {
|
|
||||||
// Check periodically if OAuth components are loaded
|
|
||||||
const authCheckInterval = setInterval(() => {
|
|
||||||
if (checkAuthStatus()) {
|
|
||||||
clearInterval(authCheckInterval);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Include OAuth assets -->
|
|
||||||
{% include "oauth-assets.html" %}
|
|
||||||
{% endblock %}
|
|
@@ -12,12 +12,6 @@
|
|||||||
{% if post.language %}
|
{% if post.language %}
|
||||||
<span class="post-lang">{{ post.language }}</span>
|
<span class="post-lang">{{ post.language }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if post.extra and post.extra.type == "ai" %}
|
|
||||||
<span class="post-ai">
|
|
||||||
<span class="ai-icon icon-ai"></span>
|
|
||||||
ai
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
<div class="pds-container">
|
|
||||||
<div class="pds-header">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Current User DID -->
|
|
||||||
<div id="userDidSection" class="user-did-section" style="display: none;">
|
|
||||||
<div class="pds-display">
|
|
||||||
<strong>PDS:</strong> <span id="userPdsText"></span>
|
|
||||||
</div>
|
|
||||||
<div class="handle-display">
|
|
||||||
<strong>Handle:</strong> <span id="userHandleText"></span>
|
|
||||||
</div>
|
|
||||||
<div class="did-display">
|
|
||||||
<span id="userDidText"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collection List -->
|
|
||||||
<div id="collectionsSection" class="collections-section" style="display: none;">
|
|
||||||
<div class="collections-header">
|
|
||||||
<button id="collectionsToggle" class="collections-toggle" onclick="toggleCollections()">[+] Collections</button>
|
|
||||||
</div>
|
|
||||||
<div id="collectionsList" class="collections-list" style="display: none;">
|
|
||||||
<!-- Collections will be populated here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AT URI Records -->
|
|
||||||
<div id="recordsSection" class="records-section" style="display: none;">
|
|
||||||
<div id="recordsList" class="records-list">
|
|
||||||
<!-- Records will be populated here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AT URI Modal -->
|
|
||||||
<div id="atUriModal" class="at-uri-modal-overlay" style="display: none;" onclick="closeAtUriModal(event)">
|
|
||||||
<div class="at-uri-modal-content">
|
|
||||||
<button class="at-uri-modal-close" onclick="closeAtUriModal()">×</button>
|
|
||||||
<div id="atUriContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
@@ -1,6 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
@@ -12,14 +12,8 @@
|
|||||||
{% if post.language %}
|
{% if post.language %}
|
||||||
<span class="article-lang">{{ post.language }}</span>
|
<span class="article-lang">{{ post.language }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if post.extra and post.extra.type == "ai" %}
|
|
||||||
<span class="article-ai">
|
|
||||||
<span class="ai-icon icon-ai"></span>
|
|
||||||
ai
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if not post.extra or not post.extra.type or post.extra.type != "ai" %}
|
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||||
<div class="article-actions">
|
<div class="article-actions">
|
||||||
{% if post.markdown_url %}
|
{% if post.markdown_url %}
|
||||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||||
@@ -35,7 +29,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if not post.extra or not post.extra.type or post.extra.type != "ai" %}
|
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h3>Contents</h3>
|
<h3>Contents</h3>
|
||||||
<div id="toc-content">
|
<div id="toc-content">
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ailog-oauth",
|
"name": "ailog-oauth",
|
||||||
"version": "0.3.4",
|
"version": "0.2.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
@@ -139,7 +139,7 @@ body {
|
|||||||
/* align-items: center; */
|
/* align-items: center; */
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 30px 0;
|
padding: 25px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +287,7 @@ body {
|
|||||||
.auth-section {
|
.auth-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-section.search-bar-layout {
|
.auth-section.search-bar-layout {
|
||||||
@@ -301,10 +302,10 @@ body {
|
|||||||
.auth-section.search-bar-layout .handle-input {
|
.auth-section.search-bar-layout .handle-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 9px 15px;
|
padding: 10px 15px;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px 0 0 4px;
|
border-radius: 8px 0 0 8px;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -318,13 +319,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-section.search-bar-layout .auth-button {
|
.auth-section.search-bar-layout .auth-button {
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 9px 15px;
|
padding: 10px 15px;
|
||||||
min-width: 50px;
|
height: 40px;
|
||||||
min-height: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auth Button */
|
/* Auth Button */
|
||||||
@@ -332,26 +332,15 @@ body {
|
|||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 9px 15px;
|
padding: 8px 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 50px;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner for auth button */
|
|
||||||
.auth-button.loading i {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-button:hover {
|
.auth-button:hover {
|
||||||
@@ -433,6 +422,10 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -926,6 +919,10 @@ body {
|
|||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.form-input, .form-textarea {
|
.form-input, .form-textarea {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
@@ -22,18 +22,9 @@ export default function App() {
|
|||||||
const [showAskAI, setShowAskAI] = useState(false)
|
const [showAskAI, setShowAskAI] = useState(false)
|
||||||
const [showTestUI, setShowTestUI] = useState(false)
|
const [showTestUI, setShowTestUI] = useState(false)
|
||||||
|
|
||||||
// Check if current page has matching chat records (AI posts always have chat records)
|
// Simple detection: if the URL contains a date+hash pattern, it's likely an AI post
|
||||||
const isAiPost = !pageContext.isTopPage && Array.isArray(adminChatRecords) && adminChatRecords.some(chatPair => {
|
const isAiPost = !pageContext.isTopPage && pageContext.rkey &&
|
||||||
const recordUrl = chatPair.question?.value?.post?.url
|
/^\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(pageContext.rkey)
|
||||||
if (!recordUrl) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
|
||||||
return recordRkey === pageContext.rkey
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Environment-based feature flags
|
// Environment-based feature flags
|
||||||
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
||||||
@@ -48,7 +39,7 @@ export default function App() {
|
|||||||
const records = await agent.api.com.atproto.repo.listRecords({
|
const records = await agent.api.com.atproto.repo.listRecords({
|
||||||
repo: user.did,
|
repo: user.did,
|
||||||
collection: 'ai.syui.log.chat',
|
collection: 'ai.syui.log.chat',
|
||||||
limit: 100
|
limit: 50
|
||||||
})
|
})
|
||||||
|
|
||||||
// Group questions and answers together
|
// Group questions and answers together
|
||||||
@@ -83,8 +74,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by creation time (oldest first) - for chronological conversation flow
|
// Sort by creation time (newest first)
|
||||||
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
setUserChatRecords(chatPairs)
|
setUserChatRecords(chatPairs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -118,14 +109,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [adminData])
|
}, [adminData])
|
||||||
|
|
||||||
// Expose current user and agent for game page
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && agent) {
|
|
||||||
window.currentUser = user
|
|
||||||
window.currentAgent = agent
|
|
||||||
}
|
|
||||||
}, [user, agent])
|
|
||||||
|
|
||||||
// Event listeners for blog communication
|
// Event listeners for blog communication
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear OAuth completion flag once app is loaded
|
// Clear OAuth completion flag once app is loaded
|
||||||
|
@@ -115,48 +115,6 @@ export const atproto = {
|
|||||||
|
|
||||||
// Use Agent's putRecord method instead of direct fetch
|
// Use Agent's putRecord method instead of direct fetch
|
||||||
return await agent.com.atproto.repo.putRecord(record)
|
return await agent.com.atproto.repo.putRecord(record)
|
||||||
},
|
|
||||||
|
|
||||||
// Find all records for a specific post by paginating through all records
|
|
||||||
async findRecordsForPost(pds, repo, collection, targetRkey) {
|
|
||||||
let cursor = null
|
|
||||||
let allMatchingRecords = []
|
|
||||||
let pageCount = 0
|
|
||||||
const maxPages = 50 // Safety limit to prevent infinite loops
|
|
||||||
|
|
||||||
do {
|
|
||||||
pageCount++
|
|
||||||
if (pageCount > maxPages) {
|
|
||||||
console.warn(`Reached max pages (${maxPages}) while searching for ${targetRkey}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.getRecords(pds, repo, collection, 100, cursor)
|
|
||||||
|
|
||||||
// Filter records that match the target post
|
|
||||||
const matchingRecords = result.records.filter(record => {
|
|
||||||
const postUrl = record.value?.post?.url
|
|
||||||
if (!postUrl) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract rkey from URL
|
|
||||||
const recordRkey = new URL(postUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
|
||||||
return recordRkey === targetRkey
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
allMatchingRecords.push(...matchingRecords)
|
|
||||||
cursor = result.cursor
|
|
||||||
|
|
||||||
// Optional: Stop early if we found some records (uncomment if desired)
|
|
||||||
// if (allMatchingRecords.length > 0) break
|
|
||||||
|
|
||||||
} while (cursor)
|
|
||||||
|
|
||||||
console.log(`Found ${allMatchingRecords.length} records for ${targetRkey} after searching ${pageCount} pages`)
|
|
||||||
return allMatchingRecords
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,53 +214,6 @@ export const collections = {
|
|||||||
return records
|
return records
|
||||||
},
|
},
|
||||||
|
|
||||||
// Find chat records for a specific post using pagination
|
|
||||||
async getChatForPost(pds, repo, collection, targetRkey) {
|
|
||||||
const cacheKey = dataCache.generateKey('chatForPost', pds, repo, collection, targetRkey)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const records = await atproto.findRecordsForPost(pds, repo, `${collection}.chat`, targetRkey)
|
|
||||||
|
|
||||||
// Process into chat pairs like the original getChat function
|
|
||||||
const chatPairs = []
|
|
||||||
const recordMap = new Map()
|
|
||||||
|
|
||||||
// First pass: organize records by base rkey
|
|
||||||
records.forEach(record => {
|
|
||||||
const rkey = record.uri.split('/').pop()
|
|
||||||
const baseRkey = rkey.replace('-answer', '')
|
|
||||||
|
|
||||||
if (!recordMap.has(baseRkey)) {
|
|
||||||
recordMap.set(baseRkey, { question: null, answer: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.value.type === 'question') {
|
|
||||||
recordMap.get(baseRkey).question = record
|
|
||||||
} else if (record.value.type === 'answer') {
|
|
||||||
recordMap.get(baseRkey).answer = record
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Second pass: create chat pairs
|
|
||||||
recordMap.forEach((pair, rkey) => {
|
|
||||||
if (pair.question) {
|
|
||||||
chatPairs.push({
|
|
||||||
rkey,
|
|
||||||
question: pair.question,
|
|
||||||
answer: pair.answer,
|
|
||||||
createdAt: pair.question.value.createdAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by creation time (oldest first) - for chronological conversation flow
|
|
||||||
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
|
||||||
|
|
||||||
dataCache.set(cacheKey, chatPairs)
|
|
||||||
return chatPairs
|
|
||||||
},
|
|
||||||
|
|
||||||
// 投稿後にキャッシュを無効化
|
// 投稿後にキャッシュを無効化
|
||||||
invalidateCache(collection) {
|
invalidateCache(collection) {
|
||||||
dataCache.invalidatePattern(collection)
|
dataCache.invalidatePattern(collection)
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||||
const [handleInput, setHandleInput] = useState('')
|
const [handleInput, setHandleInput] = useState('')
|
||||||
@@ -13,7 +12,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
|||||||
try {
|
try {
|
||||||
await onLogin(handleInput.trim())
|
await onLogin(handleInput.trim())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
alert('ログインに失敗しました: ' + error.message)
|
alert('ログインに失敗しました: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -69,9 +68,9 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isLoading || !handleInput.trim()}
|
disabled={isLoading || !handleInput.trim()}
|
||||||
className={`auth-button ${isLoading ? 'loading' : ''}`}
|
className="auth-button"
|
||||||
>
|
>
|
||||||
<i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
|
{isLoading ? 'Loading...' : <i className="fab fa-bluesky"></i>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
|
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
|
||||||
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
|
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test component to demonstrate avatar functionality
|
* Test component to demonstrate avatar functionality
|
||||||
@@ -64,7 +63,7 @@ export default function AvatarTest() {
|
|||||||
|
|
||||||
setTestResults(results)
|
setTestResults(results)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Test failed:', error)
|
console.error('Test failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -79,7 +78,7 @@ export default function AvatarTest() {
|
|||||||
batchResults: Object.fromEntries(avatarMap)
|
batchResults: Object.fromEntries(avatarMap)
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Batch test failed:', error)
|
console.error('Batch test failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,7 @@ export default function AvatarTest() {
|
|||||||
prefetchResult: cachedAvatar
|
prefetchResult: cachedAvatar
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Prefetch test failed:', error)
|
console.error('Prefetch test failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
@@ -25,24 +25,6 @@ function getCorrectWebUrl(avatarUrl) {
|
|||||||
export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
||||||
|
|
||||||
// Sort chat pairs by creation time (oldest first) for chronological conversation flow
|
|
||||||
const sortedChatPairs = Array.isArray(chatPairs)
|
|
||||||
? [...chatPairs].sort((a, b) => {
|
|
||||||
const dateA = new Date(a.createdAt)
|
|
||||||
const dateB = new Date(b.createdAt)
|
|
||||||
|
|
||||||
// If creation times are the same, sort by URI (which contains sequence info)
|
|
||||||
if (dateA.getTime() === dateB.getTime()) {
|
|
||||||
const uriA = a.question?.uri || ''
|
|
||||||
const uriB = b.question?.uri || ''
|
|
||||||
return uriA.localeCompare(uriB)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateA - dateB
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
|
|
||||||
const toggleJsonView = (key) => {
|
const toggleJsonView = (key) => {
|
||||||
const newExpanded = new Set(expandedRecords)
|
const newExpanded = new Set(expandedRecords)
|
||||||
if (newExpanded.has(key)) {
|
if (newExpanded.has(key)) {
|
||||||
@@ -53,7 +35,7 @@ export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat,
|
|||||||
setExpandedRecords(newExpanded)
|
setExpandedRecords(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sortedChatPairs || sortedChatPairs.length === 0) {
|
if (!chatPairs || chatPairs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<p>チャット履歴がありません</p>
|
<p>チャット履歴がありません</p>
|
||||||
@@ -102,7 +84,7 @@ export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
{sortedChatPairs.map((chatPair, i) => (
|
{chatPairs.map((chatPair, i) => (
|
||||||
<div key={chatPair.rkey} className="chat-conversation">
|
<div key={chatPair.rkey} className="chat-conversation">
|
||||||
{/* Question */}
|
{/* Question */}
|
||||||
{chatPair.question && (
|
{chatPair.question && (
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { atproto, collections } from '../api/atproto.js'
|
import { atproto, collections } from '../api/atproto.js'
|
||||||
import { env } from '../config/env.js'
|
import { env } from '../config/env.js'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
|
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
@@ -80,7 +79,7 @@ const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to create profile:', err)
|
console.error('Failed to create profile:', err)
|
||||||
setError(err.message || 'プロフィールの作成に失敗しました')
|
setError(err.message || 'プロフィールの作成に失敗しました')
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false)
|
setPosting(false)
|
||||||
|
@@ -125,6 +125,18 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="record-meta">
|
||||||
|
{record.value.post?.url && (
|
||||||
|
<a
|
||||||
|
href={record.value.post.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="record-url"
|
||||||
|
>
|
||||||
|
{record.value.post.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{expandedRecords.has(i) && (
|
{expandedRecords.has(i) && (
|
||||||
<div className="json-display">
|
<div className="json-display">
|
||||||
|
@@ -4,105 +4,94 @@ import ChatRecordList from './ChatRecordList.jsx'
|
|||||||
import ProfileRecordList from './ProfileRecordList.jsx'
|
import ProfileRecordList from './ProfileRecordList.jsx'
|
||||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||||
import { logger } from '../utils/logger.js'
|
import { logger } from '../utils/logger.js'
|
||||||
import { collections } from '../api/atproto.js'
|
|
||||||
import { getApiConfig } from '../utils/pds.js'
|
|
||||||
import { env } from '../config/env.js'
|
|
||||||
|
|
||||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||||
// State for page-specific chat records
|
// Simple detection: if the URL contains a date+hash pattern, it's likely an AI post
|
||||||
const [pageSpecificChatRecords, setPageSpecificChatRecords] = useState([])
|
const isAiPost = !pageContext.isTopPage && pageContext.rkey &&
|
||||||
const [pageSpecificLoading, setPageSpecificLoading] = useState(false)
|
/^\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(pageContext.rkey)
|
||||||
|
|
||||||
// Check if current page has matching chat records (AI posts always have chat records)
|
const [activeTab, setActiveTab] = useState(isAiPost ? 'collection' : 'profiles')
|
||||||
const isAiPost = !pageContext.isTopPage && Array.isArray(chatRecords) && chatRecords.some(chatPair => {
|
|
||||||
const recordUrl = chatPair.question?.value?.post?.url
|
|
||||||
if (!recordUrl) return false
|
|
||||||
|
|
||||||
try {
|
// Monitor activeTab changes
|
||||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
|
||||||
return recordRkey === pageContext.rkey
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(
|
|
||||||
isAiPost ? 'collection' : (pageContext.isTopPage ? 'profiles' : 'users')
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetch page-specific chat records for individual article pages
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pageContext.isTopPage && pageContext.rkey) {
|
logger.log('RecordTabs: activeTab changed to', activeTab)
|
||||||
const fetchPageSpecificChats = async () => {
|
}, [activeTab])
|
||||||
setPageSpecificLoading(true)
|
|
||||||
try {
|
|
||||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
|
||||||
const { atproto } = await import('../api/atproto.js')
|
|
||||||
const did = await atproto.getDid(env.pds, env.admin)
|
|
||||||
|
|
||||||
const records = await collections.getChatForPost(
|
logger.log('RecordTabs: activeTab is', activeTab)
|
||||||
apiConfig.pds,
|
logger.log('RecordTabs: commentRecords prop:', commentRecords?.length || 0, commentRecords)
|
||||||
did,
|
|
||||||
env.collection,
|
|
||||||
pageContext.rkey
|
|
||||||
)
|
|
||||||
setPageSpecificChatRecords(records)
|
|
||||||
} catch (error) {
|
|
||||||
setPageSpecificChatRecords([])
|
|
||||||
} finally {
|
|
||||||
setPageSpecificLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPageSpecificChats()
|
|
||||||
} else {
|
|
||||||
setPageSpecificChatRecords([])
|
|
||||||
}
|
|
||||||
}, [pageContext.isTopPage, pageContext.rkey])
|
|
||||||
|
|
||||||
// Filter records based on page context
|
// Filter records based on page context
|
||||||
const filterRecords = (records, isProfile = false) => {
|
const filterRecords = (records, isProfile = false) => {
|
||||||
|
// Ensure records is an array
|
||||||
const recordsArray = Array.isArray(records) ? records : []
|
const recordsArray = Array.isArray(records) ? records : []
|
||||||
|
|
||||||
|
logger.log('filterRecords called with:', {
|
||||||
|
recordsLength: recordsArray.length,
|
||||||
|
isProfile,
|
||||||
|
isTopPage: pageContext.isTopPage,
|
||||||
|
pageRkey: pageContext.rkey,
|
||||||
|
records: recordsArray
|
||||||
|
})
|
||||||
|
|
||||||
if (pageContext.isTopPage) {
|
if (pageContext.isTopPage) {
|
||||||
// Top page: show latest 3 records
|
// Top page: show latest 3 records
|
||||||
return recordsArray.slice(0, 3)
|
const result = recordsArray.slice(0, 3)
|
||||||
|
logger.log('filterRecords: Top page result:', result.length, result)
|
||||||
|
return result
|
||||||
} else {
|
} else {
|
||||||
// Individual page: show records matching the URL
|
// Individual page: show records matching the URL
|
||||||
const filtered = recordsArray.filter(record => {
|
const filtered = recordsArray.filter(record => {
|
||||||
// Profile records should always be shown
|
// Profile records should always be shown
|
||||||
if (isProfile || record.value?.type === 'profile') {
|
if (isProfile || record.value?.type === 'profile') {
|
||||||
|
logger.log('filterRecords: Profile record included:', record.value?.type)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordUrl = record.value?.post?.url
|
const recordUrl = record.value?.post?.url
|
||||||
if (!recordUrl) {
|
if (!recordUrl) {
|
||||||
|
logger.log('filterRecords: No recordUrl found for record:', record.value?.type)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||||
return recordRkey === pageContext.rkey
|
const matches = recordRkey === pageContext.rkey
|
||||||
|
logger.log('filterRecords: URL matching:', { recordRkey, pageRkey: pageContext.rkey, matches })
|
||||||
|
return matches
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.log('filterRecords: URL parsing failed for:', recordUrl)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
logger.log('filterRecords: Individual page result:', filtered.length, filtered)
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter chat records (which are already processed into pairs)
|
// Special filter for chat records (which are already processed into pairs)
|
||||||
const filterChatRecords = (chatPairs) => {
|
const filterChatRecords = (chatPairs) => {
|
||||||
|
// Ensure chatPairs is an array
|
||||||
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
|
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
|
||||||
|
|
||||||
|
logger.log('filterChatRecords called:', {
|
||||||
|
isTopPage: pageContext.isTopPage,
|
||||||
|
rkey: pageContext.rkey,
|
||||||
|
chatPairsLength: chatArray.length,
|
||||||
|
chatPairsType: typeof chatPairs,
|
||||||
|
isArray: Array.isArray(chatPairs)
|
||||||
|
})
|
||||||
|
|
||||||
if (pageContext.isTopPage) {
|
if (pageContext.isTopPage) {
|
||||||
// Top page: show latest 3 pairs
|
// Top page: show latest 3 pairs
|
||||||
return chatArray.slice(0, 3)
|
const result = chatArray.slice(0, 3)
|
||||||
|
logger.log('Top page: returning', result.length, 'pairs')
|
||||||
|
return result
|
||||||
} else {
|
} else {
|
||||||
// Individual page: show pairs matching the URL (compare path only, ignore domain)
|
// Individual page: show pairs matching the URL (compare path only, ignore domain)
|
||||||
const filtered = chatArray.filter(chatPair => {
|
const filtered = chatArray.filter(chatPair => {
|
||||||
const recordUrl = chatPair.question?.value?.post?.url
|
const recordUrl = chatPair.question?.value?.post?.url
|
||||||
if (!recordUrl) {
|
if (!recordUrl) {
|
||||||
|
logger.log('No recordUrl for chatPair:', chatPair)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,24 +100,44 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
const recordPath = new URL(recordUrl).pathname
|
const recordPath = new URL(recordUrl).pathname
|
||||||
const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
|
const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
|
||||||
|
|
||||||
|
logger.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
|
||||||
|
|
||||||
// Compare with current page rkey
|
// Compare with current page rkey
|
||||||
return recordRkey === pageContext.rkey
|
const matches = recordRkey === pageContext.rkey
|
||||||
|
if (matches) {
|
||||||
|
logger.log('Found matching chat pair!')
|
||||||
|
}
|
||||||
|
return matches
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.log('Error processing recordUrl:', recordUrl, error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
logger.log('Individual page: returning', filtered.length, 'filtered pairs')
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters to all record types
|
|
||||||
const filteredLangRecords = filterRecords(Array.isArray(langRecords) ? langRecords : [])
|
const filteredLangRecords = filterRecords(Array.isArray(langRecords) ? langRecords : [])
|
||||||
|
|
||||||
|
logger.log('RecordTabs: About to filter commentRecords:', commentRecords?.length || 0, commentRecords)
|
||||||
const filteredCommentRecords = filterRecords(Array.isArray(commentRecords) ? commentRecords : [])
|
const filteredCommentRecords = filterRecords(Array.isArray(commentRecords) ? commentRecords : [])
|
||||||
|
logger.log('RecordTabs: After filtering commentRecords:', filteredCommentRecords.length, filteredCommentRecords)
|
||||||
|
|
||||||
const filteredUserComments = filterRecords(Array.isArray(userComments) ? userComments : [])
|
const filteredUserComments = filterRecords(Array.isArray(userComments) ? userComments : [])
|
||||||
const filteredChatRecords = filterChatRecords(Array.isArray(chatRecords) ? chatRecords : [])
|
const filteredChatRecords = filterChatRecords(Array.isArray(chatRecords) ? chatRecords : [])
|
||||||
const filteredBaseRecords = filterRecords(Array.isArray(baseRecords) ? baseRecords : [])
|
const filteredBaseRecords = filterRecords(Array.isArray(baseRecords) ? baseRecords : [])
|
||||||
|
|
||||||
|
logger.log('RecordTabs: filtered results:')
|
||||||
|
logger.log(' - filteredCommentRecords:', filteredCommentRecords.length, filteredCommentRecords)
|
||||||
|
logger.log(' - filteredLangRecords:', filteredLangRecords.length)
|
||||||
|
logger.log(' - filteredUserComments:', filteredUserComments.length)
|
||||||
|
logger.log(' - pageContext:', pageContext)
|
||||||
|
logger.log('RecordTabs: TAB RENDER VALUES:')
|
||||||
|
logger.log(' - filteredCommentRecords.length for tab:', filteredCommentRecords.length)
|
||||||
|
logger.log(' - commentRecords input:', commentRecords?.length || 0)
|
||||||
|
|
||||||
// Filter profile records from baseRecords
|
// Filter profile records from baseRecords
|
||||||
const profileRecords = (Array.isArray(baseRecords) ? baseRecords : []).filter(record => record.value?.type === 'profile')
|
const profileRecords = (Array.isArray(baseRecords) ? baseRecords : []).filter(record => record.value?.type === 'profile')
|
||||||
const sortedProfileRecords = profileRecords.sort((a, b) => {
|
const sortedProfileRecords = profileRecords.sort((a, b) => {
|
||||||
@@ -144,7 +153,10 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
<div className="tab-header">
|
<div className="tab-header">
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('profiles')}
|
onClick={() => {
|
||||||
|
logger.log('RecordTabs: Profiles tab clicked')
|
||||||
|
setActiveTab('profiles')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
about ({filteredProfileRecords.length})
|
about ({filteredProfileRecords.length})
|
||||||
</button>
|
</button>
|
||||||
@@ -156,9 +168,15 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('comment')}
|
onClick={() => {
|
||||||
|
logger.log('RecordTabs: feedback tab clicked, setting activeTab to comment')
|
||||||
|
setActiveTab('comment')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
feedback ({filteredCommentRecords.length})
|
feedback ({(() => {
|
||||||
|
logger.log('RecordTabs: feedback tab render - filteredCommentRecords.length:', filteredCommentRecords.length)
|
||||||
|
return filteredCommentRecords.length
|
||||||
|
})()})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||||
@@ -207,33 +225,19 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{activeTab === 'collection' && (
|
{activeTab === 'collection' && (
|
||||||
(userChatLoading || pageSpecificLoading) ? (
|
userChatLoading ? (
|
||||||
<LoadingSkeleton count={2} showTitle={true} />
|
<LoadingSkeleton count={2} showTitle={true} />
|
||||||
) : (() => {
|
) : (
|
||||||
const chatPairsToUse = !pageContext.isTopPage && pageSpecificChatRecords.length > 0
|
|
||||||
? pageSpecificChatRecords
|
|
||||||
: (filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : []))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatRecordList
|
<ChatRecordList
|
||||||
chatPairs={chatPairsToUse}
|
chatPairs={filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : [])}
|
||||||
chatHasMore={
|
chatHasMore={filteredChatRecords.length > 0 ? chatHasMore : false}
|
||||||
!pageContext.isTopPage && pageSpecificChatRecords.length > 0
|
onLoadMoreChat={filteredChatRecords.length > 0 ? onLoadMoreChat : null}
|
||||||
? false // Page-specific records don't use pagination
|
|
||||||
: (filteredChatRecords.length > 0 ? chatHasMore : false)
|
|
||||||
}
|
|
||||||
onLoadMoreChat={
|
|
||||||
!pageContext.isTopPage && pageSpecificChatRecords.length > 0
|
|
||||||
? null // Page-specific records don't use pagination
|
|
||||||
: (filteredChatRecords.length > 0 ? onLoadMoreChat : null)
|
|
||||||
}
|
|
||||||
apiConfig={apiConfig}
|
apiConfig={apiConfig}
|
||||||
user={user}
|
user={user}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
onRecordDeleted={onRecordDeleted}
|
onRecordDeleted={onRecordDeleted}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
{activeTab === 'users' && !isAiPost && (
|
{activeTab === 'users' && !isAiPost && (
|
||||||
!userComments ? (
|
!userComments ? (
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { atproto } from '../api/atproto.js'
|
import { atproto } from '../api/atproto.js'
|
||||||
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
|
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
export default function UserLookup() {
|
export default function UserLookup() {
|
||||||
const [handleInput, setHandleInput] = useState('')
|
const [handleInput, setHandleInput] = useState('')
|
||||||
@@ -27,7 +26,7 @@ export default function UserLookup() {
|
|||||||
config: apiConfig
|
config: apiConfig
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('User lookup failed:', error)
|
console.error('User lookup failed:', error)
|
||||||
setUserInfo({ error: error.message })
|
setUserInfo({ error: error.message })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
@@ -48,7 +48,7 @@ export function useAdminData() {
|
|||||||
logger.error('getComment error:', err)
|
logger.error('getComment error:', err)
|
||||||
throw err
|
throw err
|
||||||
}),
|
}),
|
||||||
collections.getChat(apiConfig.pds, did, env.collection, 100).catch(err => {
|
collections.getChat(apiConfig.pds, did, env.collection, 10).catch(err => {
|
||||||
logger.error('getChat error:', err)
|
logger.error('getChat error:', err)
|
||||||
throw err
|
throw err
|
||||||
})
|
})
|
||||||
@@ -98,19 +98,24 @@ export function useAdminData() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by creation time (oldest first) - for chronological conversation flow
|
// Sort by creation time (newest first)
|
||||||
chatPairs.sort((a, b) => {
|
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
const dateA = new Date(a.createdAt)
|
|
||||||
const dateB = new Date(b.createdAt)
|
|
||||||
return dateA - dateB
|
|
||||||
})
|
|
||||||
|
|
||||||
|
logger.log('useAdminData: raw chat records:', chat.length)
|
||||||
|
logger.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
|
||||||
|
logger.log('useAdminData: setting state data:')
|
||||||
|
logger.log(' - records:', records.length)
|
||||||
|
logger.log(' - langRecords:', lang.length)
|
||||||
|
logger.log(' - commentRecords:', comment.length, comment)
|
||||||
|
logger.log(' - chatRecords:', chatPairs.length)
|
||||||
|
|
||||||
setAdminData({ did, profile, records, apiConfig })
|
setAdminData({ did, profile, records, apiConfig })
|
||||||
setLangRecords(lang)
|
setLangRecords(lang)
|
||||||
setCommentRecords(comment)
|
setCommentRecords(comment)
|
||||||
setChatRecords(chatPairs)
|
setChatRecords(chatPairs)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Log the actual error for debugging
|
||||||
|
logger.error('useAdminData: Error in loadAdminData:', err)
|
||||||
setError('silent_failure')
|
setError('silent_failure')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -123,7 +128,7 @@ export function useAdminData() {
|
|||||||
try {
|
try {
|
||||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||||
const did = await atproto.getDid(env.pds, env.admin)
|
const did = await atproto.getDid(env.pds, env.admin)
|
||||||
const chatResult = await collections.getChat(apiConfig.pds, did, env.collection, 100, chatCursor)
|
const chatResult = await collections.getChat(apiConfig.pds, did, env.collection, 10, chatCursor)
|
||||||
|
|
||||||
const newChatRecords = chatResult.records || chatResult
|
const newChatRecords = chatResult.records || chatResult
|
||||||
const newCursor = chatResult.cursor || null
|
const newCursor = chatResult.cursor || null
|
||||||
@@ -163,8 +168,8 @@ export function useAdminData() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort new pairs by creation time (oldest first) - for chronological conversation flow
|
// Sort new pairs by creation time (newest first)
|
||||||
newChatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
newChatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
// Append to existing chat records
|
// Append to existing chat records
|
||||||
setChatRecords(prev => [...prev, ...newChatPairs])
|
setChatRecords(prev => [...prev, ...newChatPairs])
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { OAuthService } from '../services/oauth.js'
|
import { OAuthService } from '../services/oauth.js'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
const oauthService = new OAuthService()
|
const oauthService = new OAuthService()
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ export function useAuth() {
|
|||||||
|
|
||||||
// If we're on callback page and authentication succeeded, notify parent
|
// If we're on callback page and authentication succeeded, notify parent
|
||||||
if (window.location.pathname === '/oauth/callback') {
|
if (window.location.pathname === '/oauth/callback') {
|
||||||
logger.log('OAuth callback completed, notifying parent window')
|
console.log('OAuth callback completed, notifying parent window')
|
||||||
|
|
||||||
// Get referrer or use stored return URL
|
// Get referrer or use stored return URL
|
||||||
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
|
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
|
||||||
@@ -49,7 +48,7 @@ export function useAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Auth initialization failed:', error)
|
console.error('Auth initialization failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export function usePageContext() {
|
export function usePageContext() {
|
||||||
const [pageContext, setPageContext] = useState({
|
const [pageContext, setPageContext] = useState({
|
||||||
|
@@ -67,8 +67,8 @@ export function useUserData(adminData) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by creation time (oldest first) - consistent with other components
|
// Sort by creation time (newest first)
|
||||||
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
|
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
|
||||||
setChatRecords(chatPairs)
|
setChatRecords(chatPairs)
|
||||||
|
@@ -2,7 +2,6 @@ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
|||||||
import { Agent } from '@atproto/api'
|
import { Agent } from '@atproto/api'
|
||||||
import { env } from '../config/env.js'
|
import { env } from '../config/env.js'
|
||||||
import { isSyuIsHandle } from '../utils/pds.js'
|
import { isSyuIsHandle } from '../utils/pds.js'
|
||||||
import { logger } from '../utils/logger.js'
|
|
||||||
|
|
||||||
export class OAuthService {
|
export class OAuthService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -45,7 +44,7 @@ export class OAuthService {
|
|||||||
// Try to restore session
|
// Try to restore session
|
||||||
return await this.restoreSession()
|
return await this.restoreSession()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OAuth initialization failed:', error)
|
console.error('OAuth initialization failed:', error)
|
||||||
this.initPromise = null
|
this.initPromise = null
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -90,18 +89,18 @@ export class OAuthService {
|
|||||||
displayName = profile.data.displayName || null
|
displayName = profile.data.displayName || null
|
||||||
avatar = profile.data.avatar || null
|
avatar = profile.data.avatar || null
|
||||||
|
|
||||||
logger.log('Profile fetched from session:', {
|
console.log('Profile fetched from session:', {
|
||||||
did,
|
did,
|
||||||
handle,
|
handle,
|
||||||
displayName,
|
displayName,
|
||||||
avatar: avatar ? 'present' : 'none'
|
avatar: avatar ? 'present' : 'none'
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('Failed to get profile from session:', error)
|
console.log('Failed to get profile from session:', error)
|
||||||
// Keep the basic info we have
|
// Keep the basic info we have
|
||||||
}
|
}
|
||||||
} else if (did && did.includes('test-')) {
|
} else if (did && did.includes('test-')) {
|
||||||
logger.log('Skipping profile fetch for test DID:', did)
|
console.log('Skipping profile fetch for test DID:', did)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessionInfo = {
|
this.sessionInfo = {
|
||||||
@@ -141,7 +140,7 @@ export class OAuthService {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Auth check failed:', error)
|
console.error('Auth check failed:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +168,7 @@ export class OAuthService {
|
|||||||
// Reload page
|
// Reload page
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Logout failed:', error)
|
console.error('Logout failed:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ja">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>AT URI Browser - syui.ai</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pds-browser",
|
|
||||||
"version": "0.3.4",
|
|
||||||
"description": "AT Protocol browser for ai.log",
|
|
||||||
"main": "index.js",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@atproto/api": "^0.13.0",
|
|
||||||
"@atproto/did": "^0.1.0",
|
|
||||||
"@atproto/lexicon": "^0.4.0",
|
|
||||||
"@atproto/syntax": "^0.3.0",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.0.37",
|
|
||||||
"@types/react-dom": "^18.0.11",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
|
||||||
"vite": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
128
pds/src/App.css
@@ -1,128 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
border-bottom: 3px solid #007acc;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-section {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #007acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-uris {
|
|
||||||
background: #fff;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri {
|
|
||||||
font-family: 'Monaco', 'Consolas', monospace;
|
|
||||||
background: #f4f4f4;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
display: block;
|
|
||||||
word-break: break-all;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri:hover {
|
|
||||||
background: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions {
|
|
||||||
background: #e8f4f8;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions ol {
|
|
||||||
margin: 10px 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: #007acc;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AT Browser Modal Styles */
|
|
||||||
.at-uri-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-content {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 800px;
|
|
||||||
max-height: 600px;
|
|
||||||
width: 90%;
|
|
||||||
height: 80%;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-uri-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1001;
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AT URI Link Styles */
|
|
||||||
[data-at-uri] {
|
|
||||||
color: #1976d2;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-at-uri]:hover {
|
|
||||||
color: #1565c0;
|
|
||||||
}
|
|
@@ -1,62 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { AtUriBrowser } from './components/AtUriBrowser.jsx'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AtUriBrowser>
|
|
||||||
<div className="container">
|
|
||||||
<h1>AT URI Browser</h1>
|
|
||||||
|
|
||||||
<div className="test-section">
|
|
||||||
<h2>テスト用 AT URI</h2>
|
|
||||||
<p>以下のAT URIをクリックすると、モーダルでコンテンツが表示されます。</p>
|
|
||||||
|
|
||||||
<div className="test-uris">
|
|
||||||
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222">
|
|
||||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222
|
|
||||||
</div>
|
|
||||||
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self">
|
|
||||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self
|
|
||||||
</div>
|
|
||||||
<div className="at-uri" data-at-uri="at://syui.ai/app.bsky.actor.profile/self">
|
|
||||||
at://syui.ai/app.bsky.actor.profile/self
|
|
||||||
</div>
|
|
||||||
<div className="at-uri" data-at-uri="at://bsky.app/app.bsky.actor.profile/self">
|
|
||||||
at://bsky.app/app.bsky.actor.profile/self
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="instructions">
|
|
||||||
<h3>使用方法:</h3>
|
|
||||||
<ol>
|
|
||||||
<li>上記のAT URIをクリックしてください</li>
|
|
||||||
<li>モーダルがポップアップし、AT Protocolレコードの内容が表示されます</li>
|
|
||||||
<li>モーダルは×ボタンまたはEscキーで閉じることができます</li>
|
|
||||||
<li>モーダルはレスポンシブ対応で、異なる画面サイズに対応します</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="test-section">
|
|
||||||
<h2>AT URI について</h2>
|
|
||||||
<p>AT URIは、AT Protocolで使用される統一リソース識別子です。この形式により、分散ソーシャルネットワーク上のコンテンツを一意に識別できます。</p>
|
|
||||||
<p>このブラウザを使用することで、ブログ投稿やその他のコンテンツに埋め込まれたAT URIを直接探索することが可能です。</p>
|
|
||||||
|
|
||||||
<h3>対応PDS環境</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>bsky.social</strong> - メインのBlueskyネットワーク</li>
|
|
||||||
<li><strong>syu.is</strong> - 独立したPDS環境</li>
|
|
||||||
<li><strong>plc.directory</strong> + <strong>plc.syu.is</strong> - DID解決</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><small>注意: 独立したPDS環境では、レコードの同期状況により、一部のコンテンツが利用できない場合があります。</small></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/" className="back-link">← ブログに戻る</a>
|
|
||||||
</div>
|
|
||||||
</AtUriBrowser>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
@@ -1,75 +0,0 @@
|
|||||||
/*
|
|
||||||
* AT URI Browser Component
|
|
||||||
* Copyright (c) 2025 ai.log
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { AtUriModal } from './AtUriModal.jsx'
|
|
||||||
import { isAtUri } from '../lib/atproto.js'
|
|
||||||
|
|
||||||
export function AtUriBrowser({ children }) {
|
|
||||||
const [modalUri, setModalUri] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleAtUriClick = (e) => {
|
|
||||||
const target = e.target
|
|
||||||
|
|
||||||
// Check if clicked element has at-uri data attribute
|
|
||||||
if (target.dataset.atUri) {
|
|
||||||
e.preventDefault()
|
|
||||||
setModalUri(target.dataset.atUri)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if clicked element contains at-uri text
|
|
||||||
const text = target.textContent
|
|
||||||
if (text && isAtUri(text)) {
|
|
||||||
e.preventDefault()
|
|
||||||
setModalUri(text)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if parent element has at-uri
|
|
||||||
const parent = target.parentElement
|
|
||||||
if (parent && parent.dataset.atUri) {
|
|
||||||
e.preventDefault()
|
|
||||||
setModalUri(parent.dataset.atUri)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', handleAtUriClick)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleAtUriClick)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleAtUriClick = (uri) => {
|
|
||||||
setModalUri(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setModalUri(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
<AtUriModal
|
|
||||||
uri={modalUri}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
onAtUriClick={handleAtUriClick}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility function to wrap at-uri text with clickable spans
|
|
||||||
export const wrapAtUris = (text) => {
|
|
||||||
const atUriRegex = /at:\/\/[^\s]+/g
|
|
||||||
return text.replace(atUriRegex, (match) => {
|
|
||||||
return `<span data-at-uri="${match}" style="color: blue; cursor: pointer; text-decoration: underline;">${match}</span>`
|
|
||||||
})
|
|
||||||
}
|
|
@@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
* Based on frontpage/atproto-browser
|
|
||||||
* Copyright (c) 2025 The Frontpage Authors
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { isDid } from '@atproto/did'
|
|
||||||
import { parseAtUri, isAtUri } from '../lib/atproto.js'
|
|
||||||
|
|
||||||
const JSONString = ({ data, onAtUriClick }) => {
|
|
||||||
const handleClick = (uri) => {
|
|
||||||
if (onAtUriClick) {
|
|
||||||
onAtUriClick(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<pre style={{ color: 'darkgreen', margin: 0, display: 'inline' }}>
|
|
||||||
{isAtUri(data) ? (
|
|
||||||
<>
|
|
||||||
"
|
|
||||||
<span
|
|
||||||
onClick={() => handleClick(data)}
|
|
||||||
style={{
|
|
||||||
color: 'blue',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'underline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data}
|
|
||||||
</span>
|
|
||||||
"
|
|
||||||
</>
|
|
||||||
) : isDid(data) ? (
|
|
||||||
<>
|
|
||||||
"
|
|
||||||
<span
|
|
||||||
onClick={() => handleClick(`at://${data}`)}
|
|
||||||
style={{
|
|
||||||
color: 'blue',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'underline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data}
|
|
||||||
</span>
|
|
||||||
"
|
|
||||||
</>
|
|
||||||
) : URL.canParse(data) ? (
|
|
||||||
<>
|
|
||||||
"
|
|
||||||
<a href={data} rel="noopener noreferrer ugc" target="_blank">
|
|
||||||
{data}
|
|
||||||
</a>
|
|
||||||
"
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`"${data}"`
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const JSONValue = ({ data, onAtUriClick }) => {
|
|
||||||
if (data === null) {
|
|
||||||
return <pre style={{ color: 'gray', margin: 0, display: 'inline' }}>null</pre>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
return <JSONString data={data} onAtUriClick={onAtUriClick} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data === 'number') {
|
|
||||||
return <pre style={{ color: 'darkorange', margin: 0, display: 'inline' }}>{data}</pre>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data === 'boolean') {
|
|
||||||
return <pre style={{ color: 'darkred', margin: 0, display: 'inline' }}>{data.toString()}</pre>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return (
|
|
||||||
<div style={{ paddingLeft: '20px' }}>
|
|
||||||
[
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<div key={index} style={{ paddingLeft: '20px' }}>
|
|
||||||
<JSONValue data={item} onAtUriClick={onAtUriClick} />
|
|
||||||
{index < data.length - 1 && ','}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
]
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data === 'object') {
|
|
||||||
return (
|
|
||||||
<div style={{ paddingLeft: '20px' }}>
|
|
||||||
{'{'}
|
|
||||||
{Object.entries(data).map(([key, value], index, entries) => (
|
|
||||||
<div key={key} style={{ paddingLeft: '20px' }}>
|
|
||||||
<span style={{ color: 'darkblue' }}>"{key}"</span>: <JSONValue data={value} onAtUriClick={onAtUriClick} />
|
|
||||||
{index < entries.length - 1 && ','}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{'}'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <pre style={{ margin: 0, display: 'inline' }}>{String(data)}</pre>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AtUriJson({ data, onAtUriClick }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '14px',
|
|
||||||
padding: '10px',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: '400px'
|
|
||||||
}}>
|
|
||||||
<JSONValue data={data} onAtUriClick={onAtUriClick} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* AT URI Modal Component
|
|
||||||
* Copyright (c) 2025 ai.log
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import AtUriViewer from './AtUriViewer.jsx'
|
|
||||||
|
|
||||||
export function AtUriModal({ uri, onClose, onAtUriClick }) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (e.target.classList.contains('at-uri-modal-overlay')) {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscape)
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleEscape)
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
}
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
if (!uri) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="at-uri-modal-overlay" style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1000
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
maxWidth: '800px',
|
|
||||||
maxHeight: '600px',
|
|
||||||
width: '90%',
|
|
||||||
height: '80%',
|
|
||||||
overflow: 'auto',
|
|
||||||
position: 'relative',
|
|
||||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '10px',
|
|
||||||
right: '10px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '20px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
zIndex: 1001,
|
|
||||||
padding: '5px 10px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AtUriViewer uri={uri} onAtUriClick={onAtUriClick} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,103 +0,0 @@
|
|||||||
/*
|
|
||||||
* Based on frontpage/atproto-browser
|
|
||||||
* Copyright (c) 2025 The Frontpage Authors
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { parseAtUri, getRecord } from '../lib/atproto.js'
|
|
||||||
import AtUriJson from './AtUriJson.jsx'
|
|
||||||
|
|
||||||
export default function AtUriViewer({ uri, onAtUriClick }) {
|
|
||||||
const [record, setRecord] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadRecord = async () => {
|
|
||||||
if (!uri) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const atUri = parseAtUri(uri)
|
|
||||||
if (!atUri) {
|
|
||||||
throw new Error('Invalid AT URI')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const result = await getRecord(atUri.hostname, atUri.collection, atUri.rkey)
|
|
||||||
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
setRecord(result.data)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadRecord()
|
|
||||||
}, [uri])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
||||||
<div>Loading...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', color: 'red' }}>
|
|
||||||
<div><strong>Error:</strong> {error}</div>
|
|
||||||
<div style={{ marginTop: '10px', fontSize: '12px' }}>
|
|
||||||
<strong>URI:</strong> {uri}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
|
|
||||||
デバッグ情報: このAT URIは有効ではないか、レコードが存在しません。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px' }}>
|
|
||||||
<div>No record found</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const atUri = parseAtUri(uri)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px' }}>
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>AT URI Record</h3>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#666',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
}}>
|
|
||||||
{uri}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '5px' }}>
|
|
||||||
DID: {atUri.hostname} | Collection: {atUri.collection} | RKey: {atUri.rkey}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>Record Data</h4>
|
|
||||||
<AtUriJson data={record} onAtUriClick={onAtUriClick} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,33 +0,0 @@
|
|||||||
/*
|
|
||||||
* AT Protocol Configuration for syu.is environment
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const AT_PROTOCOL_CONFIG = {
|
|
||||||
// Primary PDS environment (syu.is)
|
|
||||||
primary: {
|
|
||||||
pds: 'https://syu.is',
|
|
||||||
plc: 'https://plc.syu.is',
|
|
||||||
bsky: 'https://bsky.syu.is',
|
|
||||||
web: 'https://web.syu.is'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fallback PDS environment (bsky.social)
|
|
||||||
fallback: {
|
|
||||||
pds: 'https://bsky.social',
|
|
||||||
plc: 'https://plc.directory',
|
|
||||||
bsky: 'https://public.api.bsky.app',
|
|
||||||
web: 'https://bsky.app'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPDSConfig = (pds) => {
|
|
||||||
// Map PDS URL to appropriate config
|
|
||||||
if (pds.includes('syu.is')) {
|
|
||||||
return AT_PROTOCOL_CONFIG.primary
|
|
||||||
} else if (pds.includes('bsky.social')) {
|
|
||||||
return AT_PROTOCOL_CONFIG.fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to primary for unknown PDS
|
|
||||||
return AT_PROTOCOL_CONFIG.primary
|
|
||||||
}
|
|
@@ -1,9 +0,0 @@
|
|||||||
/*
|
|
||||||
* Based on frontpage/atproto-browser
|
|
||||||
* Copyright (c) 2025 The Frontpage Authors
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AtUriBrowser } from './components/AtUriBrowser.jsx'
|
|
||||||
export { AtUriModal } from './components/AtUriModal.jsx'
|
|
||||||
export { default as AtUriViewer } from './components/AtUriViewer.jsx'
|
|
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
* Based on frontpage/atproto-browser
|
|
||||||
* Copyright (c) 2025 The Frontpage Authors
|
|
||||||
* MIT License
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AtpBaseClient } from '@atproto/api'
|
|
||||||
import { AtUri } from '@atproto/syntax'
|
|
||||||
import { isDid } from '@atproto/did'
|
|
||||||
import { AT_PROTOCOL_CONFIG } from '../config.js'
|
|
||||||
|
|
||||||
// Identity resolution cache
|
|
||||||
const identityCache = new Map()
|
|
||||||
|
|
||||||
// Create AT Protocol client
|
|
||||||
export const createAtpClient = (pds) => {
|
|
||||||
return new AtpBaseClient({
|
|
||||||
service: pds.startsWith('http') ? pds : `https://${pds}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve identity (DID/Handle)
|
|
||||||
export const resolveIdentity = async (identifier) => {
|
|
||||||
if (identityCache.has(identifier)) {
|
|
||||||
return identityCache.get(identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let did = identifier
|
|
||||||
|
|
||||||
// If it's a handle, resolve to DID
|
|
||||||
if (!isDid(identifier)) {
|
|
||||||
// Try syu.is first, then fallback to bsky.social
|
|
||||||
let resolved = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = createAtpClient(AT_PROTOCOL_CONFIG.primary.pds)
|
|
||||||
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
|
|
||||||
did = response.data.did
|
|
||||||
resolved = true
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolved) {
|
|
||||||
try {
|
|
||||||
const client = createAtpClient(AT_PROTOCOL_CONFIG.fallback.pds)
|
|
||||||
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
|
|
||||||
did = response.data.did
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to resolve handle: ${identifier}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get DID document to find PDS
|
|
||||||
// Try plc.syu.is first, then fallback to plc.directory
|
|
||||||
let didDoc = null
|
|
||||||
let plcResponse = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.primary.plc}/${did}`)
|
|
||||||
if (plcResponse.ok) {
|
|
||||||
didDoc = await plcResponse.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// If plc.syu.is fails, try plc.directory
|
|
||||||
if (!didDoc) {
|
|
||||||
try {
|
|
||||||
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.plc}/${did}`)
|
|
||||||
if (plcResponse.ok) {
|
|
||||||
didDoc = await plcResponse.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!didDoc) {
|
|
||||||
throw new Error(`Failed to resolve DID document from any PLC server`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find PDS service endpoint
|
|
||||||
const pdsService = didDoc.service?.find(service =>
|
|
||||||
service.type === 'AtprotoPersonalDataServer' ||
|
|
||||||
service.id === '#atproto_pds'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!pdsService) {
|
|
||||||
throw new Error('No PDS service found in DID document')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
success: true,
|
|
||||||
didDocument: didDoc,
|
|
||||||
pdsUrl: pdsService.serviceEndpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
identityCache.set(identifier, result)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
const result = {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
}
|
|
||||||
identityCache.set(identifier, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get record from AT Protocol
|
|
||||||
export const getRecord = async (did, collection, rkey) => {
|
|
||||||
try {
|
|
||||||
const identityResult = await resolveIdentity(did)
|
|
||||||
|
|
||||||
if (!identityResult.success) {
|
|
||||||
return { success: false, error: identityResult.error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdsUrl = identityResult.pdsUrl
|
|
||||||
|
|
||||||
const client = createAtpClient(pdsUrl)
|
|
||||||
|
|
||||||
const response = await client.com.atproto.repo.getRecord({
|
|
||||||
repo: did,
|
|
||||||
collection,
|
|
||||||
rkey
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data,
|
|
||||||
pdsUrl
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse AT URI
|
|
||||||
export const parseAtUri = (uri) => {
|
|
||||||
try {
|
|
||||||
return new AtUri(uri)
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if string is AT URI
|
|
||||||
export const isAtUri = (str) => {
|
|
||||||
return str.startsWith('at://') && str.split(' ').length === 1
|
|
||||||
}
|
|
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.jsx'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
@@ -1,10 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
base: '/pds/',
|
|
||||||
define: {
|
|
||||||
'process.env.NODE_ENV': JSON.stringify('production')
|
|
||||||
}
|
|
||||||
})
|
|
62
scpt/run.zsh
@@ -5,7 +5,6 @@ function _env() {
|
|||||||
ailog=$d/target/release/ailog
|
ailog=$d/target/release/ailog
|
||||||
oauth=$d/oauth
|
oauth=$d/oauth
|
||||||
myblog=$d/my-blog
|
myblog=$d/my-blog
|
||||||
pds=$d/pds
|
|
||||||
port=4173
|
port=4173
|
||||||
#source $oauth/.env.production
|
#source $oauth/.env.production
|
||||||
case $OSTYPE in
|
case $OSTYPE in
|
||||||
@@ -20,49 +19,9 @@ function _env() {
|
|||||||
function _deploy_ailog() {
|
function _deploy_ailog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _sync_versions() {
|
|
||||||
# Get version from Cargo.toml
|
|
||||||
local version=$(grep '^version = ' "$d/Cargo.toml" | cut -d'"' -f2)
|
|
||||||
if [[ -z "$version" ]]; then
|
|
||||||
echo "⚠️ Could not find version in Cargo.toml"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "ℹ️ Syncing versions to $version"
|
|
||||||
|
|
||||||
# Update oauth/package.json
|
|
||||||
if [[ -f "$d/oauth/package.json" ]]; then
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
local temp_file=$(mktemp)
|
|
||||||
jq --arg version "$version" '.version = $version' "$d/oauth/package.json" > "$temp_file"
|
|
||||||
mv "$temp_file" "$d/oauth/package.json"
|
|
||||||
echo "✅ Updated oauth/package.json to $version"
|
|
||||||
else
|
|
||||||
sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"$version\"/" "$d/oauth/package.json"
|
|
||||||
rm -f "$d/oauth/package.json.bak"
|
|
||||||
echo "✅ Updated oauth/package.json to $version (sed)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update pds/package.json
|
|
||||||
if [[ -f "$d/pds/package.json" ]]; then
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
local temp_file=$(mktemp)
|
|
||||||
jq --arg version "$version" '.version = $version' "$d/pds/package.json" > "$temp_file"
|
|
||||||
mv "$temp_file" "$d/pds/package.json"
|
|
||||||
echo "✅ Updated pds/package.json to $version"
|
|
||||||
else
|
|
||||||
sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"$version\"/" "$d/pds/package.json"
|
|
||||||
rm -f "$d/pds/package.json.bak"
|
|
||||||
echo "✅ Updated pds/package.json to $version (sed)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
function _server() {
|
function _server() {
|
||||||
lsof -ti:$port | xargs kill -9 2>/dev/null || true
|
lsof -ti:$port | xargs kill -9 2>/dev/null || true
|
||||||
cd $d/my-blog
|
cd $d/my-blog
|
||||||
_sync_versions
|
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cp -rf $ailog $CARGO_HOME/bin/
|
cp -rf $ailog $CARGO_HOME/bin/
|
||||||
$ailog build
|
$ailog build
|
||||||
@@ -84,21 +43,6 @@ function _oauth_build() {
|
|||||||
#npm run preview
|
#npm run preview
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pds_build() {
|
|
||||||
cd $pds
|
|
||||||
nvm use 21
|
|
||||||
npm i
|
|
||||||
npm run build
|
|
||||||
rm -rf $myblog/static/pds
|
|
||||||
cp -rf dist $myblog/static/pds
|
|
||||||
}
|
|
||||||
|
|
||||||
function _pds_server() {
|
|
||||||
cd $pds
|
|
||||||
nvm use 21
|
|
||||||
npm run preview
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function _server_comment() {
|
function _server_comment() {
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -121,12 +65,6 @@ case "${1:-serve}" in
|
|||||||
oauth|o)
|
oauth|o)
|
||||||
_oauth_build
|
_oauth_build
|
||||||
;;
|
;;
|
||||||
pds|p)
|
|
||||||
_pds_build
|
|
||||||
;;
|
|
||||||
pds-server|ps)
|
|
||||||
_pds_server
|
|
||||||
;;
|
|
||||||
n)
|
n)
|
||||||
oauth=$d/oauth_old
|
oauth=$d/oauth_old
|
||||||
_oauth_build
|
_oauth_build
|
||||||
|
@@ -45,10 +45,7 @@ pub struct ProfileFetcher {
|
|||||||
impl ProfileFetcher {
|
impl ProfileFetcher {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::new(),
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +84,11 @@ impl ProfileFetcher {
|
|||||||
let response = self.client
|
let response = self.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.query(&[("repo", handle)])
|
.query(&[("repo", handle)])
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| anyhow::anyhow!("Request failed: {}", e))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
return Err(anyhow::anyhow!("Failed to describe repo: {}", response.status()));
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow::anyhow!("Failed to describe repo: {} - {}", status, error_text));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let repo_desc: RepoDescription = response.json().await?;
|
let repo_desc: RepoDescription = response.json().await?;
|
||||||
@@ -124,15 +117,11 @@ impl ProfileFetcher {
|
|||||||
let response = self.client
|
let response = self.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.query(&[("actor", did)])
|
.query(&[("actor", did)])
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| anyhow::anyhow!("Request failed: {}", e))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
return Err(anyhow::anyhow!("Failed to get profile: {}", response.status()));
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow::anyhow!("Failed to get profile: {} - {}", status, error_text));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile_data: Value = response.json().await?;
|
let profile_data: Value = response.json().await?;
|
||||||
|
@@ -328,7 +328,7 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
|||||||
// Remove query parameters from path
|
// Remove query parameters from path
|
||||||
let clean_path = path.split('?').next().unwrap_or(path);
|
let clean_path = path.split('?').next().unwrap_or(path);
|
||||||
|
|
||||||
let mut file_path = if clean_path == "/" {
|
let file_path = if clean_path == "/" {
|
||||||
PathBuf::from("public/index.html")
|
PathBuf::from("public/index.html")
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from("public").join(clean_path.trim_start_matches('/'))
|
PathBuf::from("public").join(clean_path.trim_start_matches('/'))
|
||||||
@@ -337,43 +337,10 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
|||||||
println!("Serving file: {}", file_path.display());
|
println!("Serving file: {}", file_path.display());
|
||||||
|
|
||||||
// Check if file exists and get metadata
|
// Check if file exists and get metadata
|
||||||
let metadata = tokio::fs::metadata(&file_path).await;
|
let metadata = tokio::fs::metadata(&file_path).await?;
|
||||||
|
if !metadata.is_file() {
|
||||||
match metadata {
|
|
||||||
Ok(meta) if meta.is_file() => {
|
|
||||||
// File exists, proceed normally
|
|
||||||
}
|
|
||||||
Ok(meta) if meta.is_dir() => {
|
|
||||||
// Directory exists, try to serve index.html
|
|
||||||
file_path = file_path.join("index.html");
|
|
||||||
println!("Directory found, trying index.html: {}", file_path.display());
|
|
||||||
let index_metadata = tokio::fs::metadata(&file_path).await?;
|
|
||||||
if !index_metadata.is_file() {
|
|
||||||
return Err(anyhow::anyhow!("No index.html in directory: {}", file_path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
// Try adding index.html to the original path
|
|
||||||
let index_path = PathBuf::from("public")
|
|
||||||
.join(clean_path.trim_start_matches('/'))
|
|
||||||
.join("index.html");
|
|
||||||
|
|
||||||
println!("File not found, trying index.html: {}", index_path.display());
|
|
||||||
let index_metadata = tokio::fs::metadata(&index_path).await;
|
|
||||||
if let Ok(meta) = index_metadata {
|
|
||||||
if meta.is_file() {
|
|
||||||
file_path = index_path;
|
|
||||||
} else {
|
|
||||||
return Err(anyhow::anyhow!("Original error: {}", e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(anyhow::anyhow!("File not found: {}", file_path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (content_type, cache_control) = match file_path.extension().and_then(|ext| ext.to_str()) {
|
let (content_type, cache_control) = match file_path.extension().and_then(|ext| ext.to_str()) {
|
||||||
Some("html") => ("text/html; charset=utf-8", "no-cache"),
|
Some("html") => ("text/html; charset=utf-8", "no-cache"),
|
||||||
|
@@ -86,12 +86,6 @@ impl Generator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDS page
|
|
||||||
self.generate_pds_page().await?;
|
|
||||||
|
|
||||||
// Generate Game page
|
|
||||||
self.generate_game_page().await?;
|
|
||||||
|
|
||||||
println!("{} {} posts", "Generated".cyan(), posts.len());
|
println!("{} {} posts", "Generated".cyan(), posts.len());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -302,8 +296,7 @@ impl Generator {
|
|||||||
"excerpt": excerpt,
|
"excerpt": excerpt,
|
||||||
"markdown_url": markdown_url,
|
"markdown_url": markdown_url,
|
||||||
"translation_url": translation_url,
|
"translation_url": translation_url,
|
||||||
"language": self.config.site.language,
|
"language": self.config.site.language
|
||||||
"extra": post.extra
|
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
@@ -498,54 +491,6 @@ impl Generator {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_pds_page(&self) -> Result<()> {
|
|
||||||
let public_dir = self.base_path.join("public");
|
|
||||||
let pds_dir = public_dir.join("pds");
|
|
||||||
fs::create_dir_all(&pds_dir)?;
|
|
||||||
|
|
||||||
// Generate PDS page using the pds.html template
|
|
||||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
|
||||||
let mut context = tera::Context::new();
|
|
||||||
context.insert("config", &config_with_timestamp);
|
|
||||||
context.insert("site", &self.config.site);
|
|
||||||
context.insert("page", &serde_json::json!({
|
|
||||||
"title": "AT URI Browser",
|
|
||||||
"description": "AT Protocol レコードをブラウズし、分散SNSのコンテンツを探索できます"
|
|
||||||
}));
|
|
||||||
|
|
||||||
let rendered_content = self.template_engine.render("pds.html", &context)?;
|
|
||||||
let output_path = pds_dir.join("index.html");
|
|
||||||
fs::write(output_path, rendered_content)?;
|
|
||||||
|
|
||||||
println!("{} PDS page", "Generated".cyan());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_game_page(&self) -> Result<()> {
|
|
||||||
let public_dir = self.base_path.join("public");
|
|
||||||
let game_dir = public_dir.join("game");
|
|
||||||
fs::create_dir_all(&game_dir)?;
|
|
||||||
|
|
||||||
// Generate Game page using the game.html template
|
|
||||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
|
||||||
let mut context = tera::Context::new();
|
|
||||||
context.insert("config", &config_with_timestamp);
|
|
||||||
context.insert("site", &self.config.site);
|
|
||||||
context.insert("page", &serde_json::json!({
|
|
||||||
"title": "Game",
|
|
||||||
"description": "Play the game with AT Protocol authentication"
|
|
||||||
}));
|
|
||||||
|
|
||||||
let rendered_content = self.template_engine.render("game.html", &context)?;
|
|
||||||
let output_path = game_dir.join("index.html");
|
|
||||||
fs::write(output_path, rendered_content)?;
|
|
||||||
|
|
||||||
println!("{} Game page", "Generated".cyan());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_plain_text(&self, html_content: &str) -> String {
|
fn extract_plain_text(&self, html_content: &str) -> String {
|
||||||
// Remove HTML tags and extract plain text
|
// Remove HTML tags and extract plain text
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
@@ -591,7 +536,6 @@ pub struct Post {
|
|||||||
pub extra: Option<serde_json::Value>,
|
pub extra: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct Translation {
|
pub struct Translation {
|
||||||
pub lang: String,
|
pub lang: String,
|
||||||
|
@@ -70,13 +70,9 @@ async fn communicate_with_claude_mcp(
|
|||||||
// Claude Code MCPプロセスを起動
|
// Claude Code MCPプロセスを起動
|
||||||
// Use the full path to avoid shell function and don't use --continue
|
// Use the full path to avoid shell function and don't use --continue
|
||||||
let claude_executable = if claude_code_path == "claude" {
|
let claude_executable = if claude_code_path == "claude" {
|
||||||
// Use dirs crate for cross-platform home directory detection
|
"/Users/syui/.claude/local/claude"
|
||||||
match dirs::home_dir() {
|
|
||||||
Some(home) => home.join(".claude/local/claude").to_string_lossy().to_string(),
|
|
||||||
None => "/Users/syui/.claude/local/claude".to_string(), // fallback
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
claude_code_path.to_string()
|
claude_code_path
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut child = tokio::process::Command::new(claude_executable)
|
let mut child = tokio::process::Command::new(claude_executable)
|
||||||
|