231 Commits

Author SHA1 Message Date
b344a0a760 fix 2026-01-08 18:28:17 +09:00
d3f08e6db5 fix 2026-01-08 18:26:27 +09:00
68ac88045b fix 2026-01-08 18:07:33 +09:00
bda6b9700d fix 2026-01-08 17:34:14 +09:00
591edf61f8 fix browser 2026-01-08 17:32:00 +09:00
14b7ccd391 start 2026-01-08 17:30:51 +09:00
e577754298 fix 2025-12-30 09:29:29 +09:00
b5914cade5 fix 2025-12-30 09:15:11 +09:00
a07c77d3f1 fix 2025-12-30 09:09:23 +09:00
1700f41e4f fix 2025-12-30 09:05:59 +09:00
cf2708c7f3 fix 2025-12-12 14:02:50 +09:00
db29524307 fix 2025-12-12 13:49:43 +09:00
e5aeffb621 fix 2025-12-11 12:46:42 +09:00
cbf3c424e3 fix 2025-12-09 17:28:48 +09:00
adf2121a8b fix 2025-12-09 17:22:31 +09:00
5ba0b77154 fix 2025-12-08 18:51:00 +09:00
22fda9cb2d fix 2025-12-08 14:13:15 +09:00
b0ca3f9163 fix 2025-12-04 18:48:42 +09:00
08c4a5cd52 fix 2025-12-04 16:45:04 +09:00
c004905d39 fix 2025-12-04 15:19:46 +09:00
a970bc008f fix 2025-12-04 14:35:17 +09:00
678c238ee7 fix 2025-11-29 07:50:48 +09:00
9f9fabd478 fix 2025-11-22 08:05:30 +09:00
acf9cacda0 fix 2025-11-22 07:31:53 +09:00
a88a61f866 fix 2025-11-22 07:22:07 +09:00
afd95636da fix 2025-11-22 07:14:49 +09:00
62ecab5f04 fix 2025-11-21 22:08:48 +09:00
d46502d177 fix 2025-11-21 22:03:38 +09:00
0d742ca1f2 fix 2025-11-21 21:46:34 +09:00
0e820f0746 fix 2025-11-21 05:34:01 +09:00
896734e265 fix 2025-11-21 05:13:23 +09:00
9535e7f08d fix 2025-11-21 05:12:12 +09:00
2d8a565a34 fix 2025-11-20 07:42:48 +09:00
3109b99959 fix css h1 2025-11-20 07:40:34 +09:00
2558fb32fa fix h1 aria-label 2025-11-20 07:28:37 +09:00
7b60de8322 fix 2025-11-20 07:17:43 +09:00
87d313575b fix 2025-11-20 07:15:27 +09:00
6d133f2d34 fix 2025-11-20 04:28:00 +09:00
84d6f7ae25 fix 2025-11-20 04:23:33 +09:00
05df0dcb7d fix 2025-11-19 08:39:28 +09:00
3f88533d2b fix 2025-11-19 06:52:21 +09:00
44d85e1b00 fix 2025-11-19 06:39:10 +09:00
f642944550 fix 2025-11-09 17:45:56 +09:00
ccdfe9cedf fix 2025-11-09 17:43:07 +09:00
6763336b2f fix 2025-11-09 17:41:19 +09:00
0b594e9a2b fix 2025-11-09 17:37:18 +09:00
281729460a fix 2025-11-09 17:33:49 +09:00
8f0e714a05 fix 2025-11-07 16:34:38 +09:00
18898e4ed5 fix 2025-11-07 16:28:51 +09:00
1dc64b9110 fix 2025-11-07 16:28:28 +09:00
8074096447 fix 2025-11-07 16:22:55 +09:00
e4b330ae27 fix 2025-11-06 19:50:00 +09:00
026f7e963b fix 2025-10-28 13:34:06 +09:00
9ecd293693 fix 2025-10-28 13:24:56 +09:00
e41f007e8e fix 2025-10-21 05:08:52 +09:00
01012424e8 fix 2025-10-20 07:27:10 +09:00
f26717c50d fix 2025-10-20 06:38:18 +09:00
edce421464 fix 2025-10-19 20:42:26 +09:00
eaf332e208 fix 2025-10-19 19:45:35 +09:00
0a79bcf73e fix 2025-10-19 19:45:19 +09:00
eb5c7a35d0 fix 2025-10-19 19:44:24 +09:00
ab7dfd90c5 fix 2025-10-19 19:41:03 +09:00
1835688595 fix 2025-10-19 16:49:29 +09:00
0135f6cb7a fix 2025-10-19 12:40:52 +09:00
d448278989 fix 2025-10-19 12:25:45 +09:00
837ff28a3d fix 2025-10-11 07:53:47 +09:00
99a8e4e6bd fix 2025-10-09 01:38:40 +09:00
bd5ef0809c fix 2025-10-09 01:32:04 +09:00
3d7021b446 fix 2025-10-03 20:23:47 +09:00
297381bc0f fix 2025-10-03 19:32:00 +09:00
00e0b301ce fix 2025-09-26 13:45:14 +09:00
331d11f319 fix 2025-09-26 12:16:04 +09:00
c4bb4f1f0f fix 2025-09-25 23:08:09 +09:00
9ff70eb1b0 fix 2025-09-25 18:54:33 +09:00
abfdf1d016 fix 2025-09-25 00:22:55 +09:00
a9338e13a6 fix 2025-09-24 15:00:03 +09:00
96da3eb52b fix 2025-09-24 14:44:19 +09:00
d4a2ea4e24 fix 2025-09-24 13:24:58 +09:00
85c49d4657 fix 2025-09-24 13:21:20 +09:00
8b61f2fa72 fix 2025-09-24 13:10:45 +09:00
92d5c0da32 fix 2025-09-19 11:36:19 +09:00
6a8e160540 fix 2025-09-19 11:30:58 +09:00
9e31a0fb0a fix 2025-09-19 10:57:24 +09:00
1c6cf7d063 fix 2025-09-19 10:55:55 +09:00
dd2e3b4fb9 add post 2025-09-10 20:34:10 +09:00
976107903b fix post-page default tab user-comment 2025-08-10 08:31:44 +09:00
2aa6616209 fix comment record-url 2025-08-10 08:25:12 +09:00
a2ec41bf87 fix post 2025-08-10 07:59:46 +09:00
3af175f4ed Fix: Update cache key to include Cargo.toml hash
This forces cache invalidation when version changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-10 04:59:27 +09:00
0e1dff12c2 add binary 2025-08-10 04:59:27 +09:00
5708e9c53d fix cargo version gh-actions 2025-08-09 18:37:58 +09:00
824aee7b74 fix temp 2025-08-09 18:07:40 +09:00
aff1b3a7bd add binary 2025-08-09 18:04:20 +09:00
fa524138c6 fix home dir 2025-08-09 18:02:09 +09:00
6047fa4161 fix type md ai 2025-08-09 17:55:13 +09:00
c5a70a433b cleanup 2025-08-09 16:53:25 +09:00
0d90ba21e0 fix listrecord-created-sort 2025-08-09 16:50:47 +09:00
e1eab122c8 fix post title 2025-08-09 11:52:54 +09:00
c3cb3db680 add post, fix profile err 2025-08-09 11:51:58 +09:00
55745ff051 rm log 2025-08-09 11:51:58 +09:00
5aeeba106a add post 2025-07-30 19:30:01 +09:00
f1e76ab31f fix post 2025-07-27 05:04:01 +09:00
3c9ef78696 add binary 2025-07-26 20:54:23 +09:00
ee2d21b0f3 update 2025-07-26 20:00:16 +09:00
0667ac58fb test game 2025-07-26 19:51:55 +09:00
d89855338b fix css 2025-07-18 10:57:42 +09:00
e19170cdff add pds.html 2025-07-18 00:05:04 +09:00
c3e22611f5 fix layout 2025-07-17 23:57:08 +09:00
2943c94ec1 binary 2025-07-17 22:23:14 +09:00
f27997b7e8 rm pds asset 2025-07-17 22:20:25 +09:00
447e4bded9 update 2025-07-17 22:12:06 +09:00
03161a52ca fix oauth-ai-chat 2025-07-17 19:26:40 +09:00
fe9381a860 fix blog post 2025-07-17 19:26:40 +09:00
f0cea89005 fix oauth filter 2025-07-16 22:57:09 +09:00
b059fe1de0 fix comment, rm console.log 2025-07-16 22:53:01 +09:00
07b0b0f702 fix css 2025-07-16 20:58:42 +09:00
ecd69557fe oauth markdown 2025-07-16 20:42:50 +09:00
452a0fda6a fix blog post 2025-07-16 11:47:15 +09:00
a62dd82790 fix config 2025-07-16 11:27:37 +09:00
3faec33bac fix blog post 2025-07-16 11:04:50 +09:00
33402f4a21 add blog post 2025-07-16 11:04:02 +09:00
3e65bc8210 binary 2025-07-16 10:18:03 +09:00
16d724ec25 update 2025-07-16 10:08:43 +09:00
69182a1bf8 update 2025-07-16 09:33:46 +09:00
0110773592 test ai-blog 2025-07-16 09:32:45 +09:00
75f108e7b8 fix blog post link 2025-07-14 15:27:10 +09:00
263189ce72 add blog post 2025-07-14 14:11:55 +09:00
7800a655f3 fix profile 2025-07-13 08:12:40 +09:00
76c797e4d8 add blog post 2025-07-13 07:52:43 +09:00
d1a1c92842 update binary 2025-07-11 13:38:22 +09:00
9da1f87640 fix update version 2025-07-11 13:09:15 +09:00
ddfc43512c add md msg 2025-07-11 08:52:34 +09:00
b3ccd61935 add my-blog msg 2025-07-11 08:51:46 +09:00
a243b6a44e fix post filename 2025-07-05 15:42:36 +09:00
e3c1cf4790 fix build err 2025-07-05 15:31:04 +09:00
a6236661bf post 2025-07-05 15:30:55 +09:00
195a4474c9 fix config.toml 2025-07-01 21:22:48 +09:00
4a34a6ca59 rm my-blog/oauth 2025-07-01 21:20:26 +09:00
4d01fb8507 fix oauth network err 2025-07-01 19:48:49 +09:00
d69c9aa09b update binary 2025-07-01 06:22:15 +09:00
99ee49f76e feat: add server-side image comparison shortcode support
- Add {{< img-compare >}} and [img-compare] shortcode syntax
- Implement server-side shortcode processing in Rust
- Create dedicated shortcode module for extensibility
- Fix image comparison slider display issues
- Remove caption display for cleaner UI
- Update to version 0.2.6

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 06:09:39 +09:00
19c0e28668 add post 2025-07-01 06:02:31 +09:00
bc99eb0814 update img-slider 2025-07-01 06:02:25 +09:00
cf93721bad fix social-app uri 2025-06-26 19:56:13 +09:00
8a8a121f4a fix delete record 2025-06-25 23:14:27 +09:00
be2bcae1d6 fix test ask-AI oauth profile 2025-06-25 23:03:50 +09:00
2c08a4acfb test blog profile 2025-06-25 21:18:13 +09:00
7791399314 fix claude-code proxy 2025-06-24 22:55:16 +09:00
26b1b2cf87 fix mobile css 2025-06-22 01:50:49 +09:00
7eb653f569 fix layout article.article-content 2025-06-22 01:16:59 +09:00
0fc920c844 fix layout 2025-06-22 00:35:54 +09:00
13c05d97d2 add claude-code-mcp-server 2025-06-22 00:26:20 +09:00
71acd44810 fix layout font-size 2025-06-22 00:25:44 +09:00
1b4579d0f1 fix layout font-size 2025-06-22 00:25:04 +09:00
09100f6d99 fix ask-ai prompt userdata 2025-06-22 00:01:27 +09:00
169de9064a fix link github 2025-06-21 19:11:01 +09:00
097c794623 fix oauth bsky button 2025-06-21 18:30:39 +09:00
b652e01dd3 fix oauth loading button 2025-06-21 17:03:23 +09:00
31af524303 fix layout 2025-06-21 15:46:21 +09:00
6be024864d cleanup docs 2025-06-21 00:07:22 +09:00
eef1fdad38 fix layout 2025-06-20 23:26:32 +09:00
b7e411e8b2 add img 2025-06-20 23:26:11 +09:00
8f9d803a94 fix gh-actions 2025-06-20 00:08:23 +09:00
f9b9c2ab52 fix layout 2025-06-20 00:00:51 +09:00
210ce801f1 update binary 2025-06-20 00:00:29 +09:00
6cb46f2ca1 fix token refresh 2025-06-19 23:01:41 +09:00
9406597b82 add post 2025-06-19 22:08:43 +09:00
0dbc3ba67e fix html text 2025-06-19 21:22:01 +09:00
a7e6fc4a1a Release v0.2.4: Complete OAuth system with AI chat and mobile support
- Fixed OAuth authentication with ATProto integration
- Implemented Ask AI functionality with conversation history
- Resolved PDS/web link issues for cross-network compatibility
- Added comprehensive mobile responsive design
- Enhanced comment posting with loading states and auto-refresh
- Improved chat record display with question/answer pairing
- Fixed tab scrolling and layout overflow issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 20:02:24 +09:00
3adcfdacf5 fix post commnet 2025-06-19 19:58:40 +09:00
004081337c fix ask-ai put 2025-06-19 19:52:31 +09:00
5ce0e0fd7a fix ask-ai 2025-06-19 19:18:50 +09:00
f816abb84f fix mobile css, ask-ai 2025-06-19 19:12:29 +09:00
8541af9293 add binary 2025-06-19 17:26:48 +09:00
68b49d5aaf Fix jetstream monitoring for ai.syui.log collections
- Fixed JetstreamMessage struct to correctly parse collection from commit object
- Fixed user list JSON format to match oauth app expectations (removed metadata field)
- Added monitoring for both ai.syui.log and ai.syui.log.chat.comment collections
- Improved error handling and debug output for stream processing
- Jetstream auto user registration now working correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 17:14:11 +09:00
53dab3fd09 fix ailog stream server 2025-06-19 17:13:02 +09:00
5fac689f98 fix hugo callback 2025-06-19 17:13:02 +09:00
293421b7a5 add callback test 2025-06-19 14:47:25 +09:00
1793de40c1 fix rm callback page 2025-06-19 13:17:32 +09:00
30bdd7b633 fix oauth package name 2025-06-19 13:09:37 +09:00
b17ac3d91a v0.2.2: OAuth authentication system improvements
🔧 OAuth Fixes:
- Add transition:generic scope to resolve authentication errors
- Improve Agent creation with session object and dpopFetch fallback
- Fix avatar fetching to use correct public API endpoints
- Proper PDS endpoint selection (bsky.syu.is vs public.api.bsky.app)

🎨 UI Improvements:
- Remove 'Demo' text from loading states
- Environment-based feature toggles (TestUI/Debug)
- Unified padding system (20px 0)
- CSS conflict resolution with oauth- prefix

🚀 Production Ready:
- Automatic feature disable in production build
- Session management improvements
- Error handling enhancements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 11:56:06 +09:00
81f87d0462 fix loading 2025-06-19 11:41:01 +09:00
a020fa24d8 fix gh-actions oauth-session 2025-06-19 11:34:56 +09:00
21c53010b7 test oauth_new gh-actions 2025-06-19 11:13:11 +09:00
4f7834f85c fix npm env production 2025-06-18 19:16:25 +09:00
fecd927b91 fix oauth_new env test 2025-06-18 18:16:37 +09:00
b54e8089ea oauth_new 2025-06-18 17:25:42 +09:00
174cb12d4d test merge 2025-06-18 10:53:48 +09:00
a1186f8185 Merge branch 'test-oauth' 2025-06-18 10:53:31 +09:00
833549756b fix did check 2025-06-17 22:36:33 +09:00
4edde5293a Add oauth_new: Complete OAuth authentication and comment system
- Created new oauth_new directory with clean OAuth implementation
- Added 4-tab interface: Lang, Comment, Collection, User Comments
- Implemented OAuth authentication with @atproto/oauth-client-browser
- Added comment posting functionality with putRecord
- Added proper PDS detection and error handling
- Skipped placeholder users to prevent errors
- Built comprehensive documentation (README.md, DEVELOPMENT.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 22:34:03 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
fccf75949c v0.2.1: Fix async trait implementation warnings
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 10:42:15 +09:00
6600a9e0cf test pds oauth did 2025-06-17 10:41:22 +09:00
0d79af5aa5 v0.2.0: Unified AI content display and OAuth PDS fixes
Major Changes:
- Unified AI content rendering across all collection types (chat, lang, comment)
- Fixed PDS endpoint detection and usage based on handle configuration
- Removed hardcoded 'yui.syui.ai' references and used environment variables
- Fixed OAuth app 400 errors by adding null checks for API calls
- Improved AI DID resolution to use correct ai.syui.ai account
- Fixed avatar and profile link generation for correct PDS routing
- Enhanced network configuration mapping for different PDS types

OAuth App Improvements:
- Consolidated renderAIContent() function for all AI collections
- Fixed generateProfileUrl() to use PDS-specific web URLs
- Removed duplicate AI content rendering code
- Added proper error handling for API calls

Technical Fixes:
- Updated stream.rs to use correct AI DID defaults
- Improved CORS handling for Ollama localhost connections
- Enhanced PDS detection logic for handle-based routing
- Cleaned up production code (removed console.log statements)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 01:51:11 +09:00
db04af76ab test cleanup 2025-06-17 01:48:30 +09:00
5f0b09b555 add binary 2025-06-16 22:48:38 +09:00
8fa9e474d1 v0.1.9: Production deployment ready
🚀 Production Features
- Console output cleanup: Removed all console.log/warn/error from OAuth app
- Clean UI: Removed debug info divs from production build
- Warning-free builds: Fixed all Rust compilation warnings

🔧 Authentication & Stream Improvements
- Enhanced password authentication with PDS specification support
- Fixed AI DID resolution: Now correctly uses ai.syui.ai (did:plc:6qyecktefllvenje24fcxnie)
- Improved project directory config loading for ailog stream commands
- Added user list initialization commands with proper PDS detection

📚 Documentation
- Complete command reference in docs/commands.md
- Architecture documentation in docs/architecture.md
- Getting started guide in docs/getting-started.md

🛠️ Technical Improvements
- Project-aware AI config loading from config.toml
- Runtime DID resolution for OAuth app
- Proper handle/DID distinction across all components
- Enhanced error handling with clean user feedback

🔐 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 22:29:46 +09:00
5339dd28b0 test scpt 2025-06-16 22:27:20 +09:00
1e83b50e3f test cli stream 2025-06-16 22:09:04 +09:00
889ce8baa1 test oauth pds 2025-06-16 20:45:55 +09:00
286b46c6e6 fix systemd 2025-06-16 12:17:42 +09:00
b780d27ace update binary 2025-06-16 12:17:29 +09:00
831fcb7865 v0.1.8: Enhanced OAuth search bar and configurable AI settings
- Transform auth-section to search bar layout (input left, button right)
- Change atproto button text to "@" symbol
- Add num_predict configuration in config.toml for AI response length
- Improve mobile responsiveness for auth section
- Remove auth-status section for cleaner UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 11:45:24 +09:00
3f8bbff7c2 fix layout oauth-bar 2025-06-16 11:43:15 +09:00
5cb73a9ed3 add scpt 2025-06-16 10:55:30 +09:00
6ce8d44c4b cleanup 2025-06-16 10:53:42 +09:00
167cfb35f7 fix tab name 2025-06-16 02:29:37 +09:00
c8377ceabf rm auth-status 2025-06-16 02:25:00 +09:00
e917c563f2 update layout 2025-06-16 02:21:26 +09:00
a76933c23b cleanup 2025-06-16 01:16:36 +09:00
8d960b7a40 fix ask-ai enter 2025-06-15 23:33:22 +09:00
d3967c782f rm html 2025-06-15 23:23:12 +09:00
63b6fd5142 fix ai handle 2025-06-15 23:21:15 +09:00
27935324c7 fix mobile css 2025-06-15 22:56:34 +09:00
594d7e7aef v0.1.7: Enhanced UI and accessibility improvements
- Add CSS styling for chat messages with theme color border
- Fix comment form visibility (only show on Comments tab)
- Remove comment form heading for cleaner UI
- Add accessibility attributes (id/name) to all form fields
- Fix Japanese input handling in Ask AI (prevent accidental submission during IME composition)
- Unified CSS classes across all content types (comments, AI chat, translations)
- Fix rkey filtering to handle .html extensions consistently

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 22:36:19 +09:00
be86c11e74 fix comment-tab 2025-06-15 22:34:44 +09:00
619675b551 fix post-page rkey 2025-06-15 22:22:01 +09:00
d4d98e2e91 v0.1.6: Major improvements to OAuth display and stream configuration
- Fix AI Chat History display layout and content formatting
- Unify comment layout structure across all comment types
- Remove hardcoded values from stream.rs, add config.toml support
- Optimize AI comment generation with character limits
- Improve translation length limits (3000 characters)
- Add comprehensive AI configuration management

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 22:22:01 +09:00
8dac463345 test update json 2025-06-15 22:22:01 +09:00
095f6ec386 v0.1.5: Unify collection configuration under VITE_OAUTH_COLLECTION
- Remove AILOG_OAUTH_COLLECTION backward compatibility
- Update stream.rs to use simplified collection structure
- Fix collection loading from project config.toml
- Resolve compiler warnings with #[allow(dead_code)]
- All systems now use unified VITE_OAUTH_COLLECTION variable

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 15:25:52 +09:00
c12d42882c test update 2025-06-15 15:23:32 +09:00
67b241f1e8 rm at-uri add post-url 2025-06-15 13:02:50 +09:00
4206b2195d fix post 2025-06-15 11:30:19 +09:00
b3c1b01e9e fix mobile css 2025-06-15 09:37:49 +09:00
ffa4fa0846 add scpt 2025-06-14 21:55:28 +09:00
0e75d4c0e6 fix comment input 2025-06-14 21:09:10 +09:00
b7f62e729a fix ask-AI 2025-06-14 20:48:17 +09:00
3b2c53fc97 Add GitHub Actions workflows and optimize build performance
- Add release.yml for multi-platform binary builds (Linux, macOS, Windows)
- Add gh-pages-fast.yml for fast deployment using pre-built binaries
- Add build-binary.yml for standalone binary artifact creation
- Optimize Cargo.toml with build profiles and reduced tokio features
- Remove 26MB of unused Font Awesome assets (kept only essential files)
- Font Awesome reduced from 28MB to 1.2MB

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 19:52:08 +09:00
166 changed files with 2845 additions and 20020 deletions

View File

@@ -1,52 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)",
"Bash(cargo:*)",
"Bash(find:*)",
"Bash(mkdir:*)",
"Bash(../target/debug/ailog new:*)",
"Bash(../target/debug/ailog build)",
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"WebFetch(domain:docs.anthropic.com)",
"WebFetch(domain:github.com)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(timeout:*)",
"Bash(grep:*)",
"Bash(./target/debug/ailog:*)",
"Bash(cat:*)",
"Bash(npm install)",
"Bash(npm run build:*)",
"Bash(chmod:*)",
"Bash(./scripts/tunnel.sh:*)",
"Bash(PRODUCTION=true cargo run -- build)",
"Bash(cloudflared tunnel:*)",
"Bash(npm install:*)",
"Bash(./scripts/build-oauth-partial.zsh:*)",
"Bash(./scripts/quick-oauth-update.zsh:*)",
"Bash(../target/debug/ailog serve)",
"Bash(./scripts/test-oauth.sh:*)",
"Bash(./run.zsh:*)",
"Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)",
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)",
"WebFetch(domain:docs.bsky.app)",
"WebFetch(domain:syui.ai)",
"Bash(rustup target list:*)",
"Bash(rustup target:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)"
],
"deny": []
}
}

View File

@@ -0,0 +1,123 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '21'
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
rm -rf my-blog/static/assets
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../bin/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"

View File

@@ -0,0 +1,193 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., v1.0.0)'
required: true
default: 'v0.1.0'
permissions:
contents: write
actions: read
env:
CARGO_TERM_COLOR: always
OPENSSL_STATIC: true
OPENSSL_VENDOR: true
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-x86_64
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-aarch64
- target: x86_64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-x86_64
- target: aarch64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-aarch64
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux)
if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
- name: Configure cross-compilation (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
# Use appropriate strip command for cross-compilation
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
else
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
fi
# Create archive
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
else
tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
fi
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate release notes
run: |
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
echo "### Features" >> release_notes.md
echo "- AI-powered static blog generator" >> release_notes.md
echo "- AtProto OAuth integration" >> release_notes.md
echo "- Automatic translation support" >> release_notes.md
echo "- AI comment system" >> release_notes.md
echo "" >> release_notes.md
echo "### Platforms" >> release_notes.md
echo "- Linux (x86_64, aarch64)" >> release_notes.md
echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
echo "" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "# Linux/macOS" >> release_notes.md
echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
echo "chmod +x ailog" >> release_notes.md
echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`" >> release_notes.md
- name: Get tag name
id: tag_name
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release with Gitea API
run: |
# Prepare release files
mkdir -p release
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
# Create release via Gitea API
RELEASE_RESPONSE=$(curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "${{ steps.tag_name.outputs.tag }}",
"name": "ailog ${{ steps.tag_name.outputs.tag }}",
"body": "'"$(cat release_notes.md | sed 's/"/\\"/g' | tr '\n' ' ')"'",
"draft": false,
"prerelease": '"$(if echo "${{ steps.tag_name.outputs.tag }}" | grep -E "(alpha|beta|rc)"; then echo "true"; else echo "false"; fi)"'
}')
# Get release ID
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Created release with ID: $RELEASE_ID"
# Upload release assets
for file in release/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
echo "Uploading $filename..."
curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file"
fi
done

View File

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

View File

@@ -6,6 +6,10 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
env:
OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,36 +24,88 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '21' node-version: '25'
- name: Install dependencies - name: Install dependencies
run: | run: |
cd oauth cd ${{ env.OAUTH_DIR }}
npm install npm install
- name: Build OAuth app - name: Build OAuth app
run: | run: |
cd oauth cd ${{ env.OAUTH_DIR }}
npm run build NODE_ENV=production npm run build
- name: Copy OAuth build to static - name: Copy OAuth build to static
run: | run: |
mkdir -p my-blog/static/assets rm -rf my-blog/static/assets
cp -r oauth/dist/assets/* my-blog/static/assets/ cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp oauth/dist/index.html my-blog/static/oauth/index.html || true cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Setup Rust - name: Build PDS app
uses: actions-rs/toolchain@v1 run: |
cd pds
npm install
npm run build
- name: Copy PDS build to static
run: |
rm -rf my-blog/static/pds
cp -rf pds/dist my-blog/static/pds
- name: Cache ailog binary
uses: actions/cache@v4
with: with:
toolchain: stable path: ./bin
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
restore-keys: |
ailog-bin-${{ runner.os }}-v
- name: Build ailog - name: Setup ailog binary
run: cargo build --release run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog - name: Build site with ailog
run: | run: |
cd my-blog cd my-blog
../target/release/ailog build ../bin/ailog build
- name: List public directory - name: List public directory
run: | run: |
@@ -64,3 +120,50 @@ jobs:
directory: my-blog/public directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }} gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3' wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
if [ -z "$DEPLOYMENT_IDS" ]; then
echo "No old deployments to delete"
exit 0
fi
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
if [ "$SUCCESS" = "true" ]; then
echo "Successfully deleted deployment: $ID"
else
echo "Failed to delete deployment: $ID"
echo "$RESPONSE" | jq .
fi
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

View File

@@ -0,0 +1,92 @@
name: github pages (fast)
on:
push:
branches:
- main
paths-ignore:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build with ailog
env:
TZ: "Asia/Tokyo"
run: |
# Use pre-built ailog binary instead of cargo build
cd my-blog
../bin/ailog build
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./my-blog/public
publish_branch: gh-pages

View File

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

View File

@@ -11,6 +11,10 @@ on:
required: true required: true
default: 'v0.1.0' default: 'v0.1.0'
permissions:
contents: write
actions: read
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
OPENSSL_STATIC: true OPENSSL_STATIC: true
@@ -44,6 +48,7 @@ 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:
@@ -103,14 +108,15 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.asset_name }} name: ${{ matrix.asset_name }}
path: | path: ${{ matrix.asset_name }}.tar.gz
${{ matrix.asset_name }}.tar.gz
${{ matrix.asset_name }}.zip
release: release:
name: Create Release name: Create Release
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -159,8 +165,6 @@ jobs:
body_path: release_notes.md body_path: release_notes.md
draft: false draft: false
prerelease: ${{ contains(steps.tag_name.outputs.tag, 'alpha') || contains(steps.tag_name.outputs.tag, 'beta') || contains(steps.tag_name.outputs.tag, 'rc') }} prerelease: ${{ contains(steps.tag_name.outputs.tag, 'alpha') || contains(steps.tag_name.outputs.tag, 'beta') || contains(steps.tag_name.outputs.tag, 'rc') }}
files: | files: artifacts/*/ailog-*.tar.gz
artifacts/*/ailog-*.tar.gz
artifacts/*/ailog-*.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

13
.gitignore vendored
View File

@@ -1,13 +1,12 @@
/target /target
/Cargo.lock /Cargo.lock
/public /public
*.swp /dist
*.swo /repos
*~ /pds/dist
.DS_Store .DS_Store
cloudflare-config.yml .config
my-blog/public/ .claude
dist
node_modules node_modules
package-lock.json package-lock.json
my-blog/static/assets/comment-atproto-* claude.md

View File

@@ -1,80 +1,21 @@
[package] [package]
name = "ailog" name = "ailog"
version = "0.1.0" version = "0.3.5"
edition = "2021" edition = "2021"
authors = ["syui"] authors = ["syui"]
description = "A static blog generator with AI features" description = "static site generator for atproto"
license = "MIT" license = "MIT"
[[bin]]
name = "ailog"
path = "src/main.rs"
[dependencies] [dependencies]
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11" pulldown-cmark = "0.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] } tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs"] }
anyhow = "1.0" anyhow = "1.0"
toml = "0.8"
chrono = "0.4"
tera = "1.20"
walkdir = "2.5"
gray_matter = "0.2"
fs_extra = "1.3"
colored = "2.1"
serde_yaml = "0.9"
syntect = "5.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
rand = "0.8" dirs = "5.0"
sha2 = "0.10" chrono = "0.4"
base64 = "0.22" walkdir = "2.5"
uuid = { version = "1.11", features = ["v4"] }
urlencoding = "2.1"
axum = "0.7" axum = "0.7"
tower = "0.5" tower-http = { version = "0.5", features = ["fs"] }
tower-http = { version = "0.5", features = ["cors", "fs"] }
hyper = { version = "1.0", features = ["full"] }
tower-sessions = "0.12"
jsonwebtoken = "9.2"
cookie = "0.18"
# Documentation generation dependencies
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
quote = "1.0"
ignore = "0.4"
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
regex = "1.0"
# ATProto and stream monitoring dependencies
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
[dev-dependencies]
tempfile = "3.14"
[profile.dev]
# Speed up development builds
opt-level = 0
debug = true
debug-assertions = true
overflow-checks = true
lto = false
panic = 'unwind'
incremental = true
codegen-units = 256
[profile.release]
# Optimize release builds for speed and size
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = true
panic = 'abort'
incremental = false
codegen-units = 1
[profile.dev.package."*"]
# Optimize dependencies in dev builds
opt-level = 3

View File

@@ -1,32 +0,0 @@
# Multi-stage build for ailog
FROM rust:1.75 as builder
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
# Copy blog content
COPY my-blog ./blog
# Build static site
RUN ailog build blog
# Expose port
EXPOSE 8080
# Run server
CMD ["ailog", "serve", "blog"]

683
README.md
View File

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

View File

@@ -1,148 +0,0 @@
name: 'ailog Static Site Generator'
description: 'AI-powered static blog generator with atproto integration'
author: 'syui'
branding:
icon: 'book-open'
color: 'orange'
inputs:
content-dir:
description: 'Content directory containing markdown files'
required: false
default: 'content'
output-dir:
description: 'Output directory for generated site'
required: false
default: 'public'
template-dir:
description: 'Template directory'
required: false
default: 'templates'
static-dir:
description: 'Static assets directory'
required: false
default: 'static'
config-file:
description: 'Configuration file path'
required: false
default: 'ailog.toml'
ai-integration:
description: 'Enable AI features'
required: false
default: 'true'
atproto-integration:
description: 'Enable atproto/OAuth features'
required: false
default: 'true'
outputs:
site-url:
description: 'Generated site URL'
value: ${{ steps.generate.outputs.site-url }}
build-time:
description: 'Build time in seconds'
value: ${{ steps.generate.outputs.build-time }}
runs:
using: 'composite'
steps:
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Check and update ailog binary
shell: bash
run: |
# Get latest release version (for Gitea, adjust API endpoint if needed)
if command -v curl >/dev/null 2>&1; then
LATEST_VERSION=$(curl -s https://api.github.com/repos/syui/ailog/releases/latest | jq -r .tag_name 2>/dev/null || echo "v0.1.1")
else
LATEST_VERSION="v0.1.1" # fallback version
fi
echo "Target version: $LATEST_VERSION"
# Check current binary version if exists
mkdir -p ./bin
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version | awk '{print $2}' 2>/dev/null || echo "unknown")
echo "Current version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Download if version is different or binary doesn't exist
if [ "$CURRENT_VERSION" != "${LATEST_VERSION#v}" ]; then
echo "Downloading ailog $LATEST_VERSION..."
# Try GitHub first, then fallback to local build
if curl -sL https://github.com/syui/ailog/releases/download/$LATEST_VERSION/ailog-linux-x86_64.tar.gz | tar -xzf - 2>/dev/null; then
mv ailog ./bin/ailog
chmod +x ./bin/ailog
echo "Downloaded binary: $(./bin/ailog --version)"
else
echo "Download failed, building from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
cp ./target/release/ailog ./bin/ailog
echo "Built from source: $(./bin/ailog --version)"
else
echo "Error: Neither download nor cargo build available"
exit 1
fi
fi
else
echo "Binary is up to date"
chmod +x ./bin/ailog
fi
- name: Setup Node.js for OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
shell: bash
run: |
if [ -d "oauth" ]; then
cd oauth
npm install
npm run build
cp -r dist/* ../${{ inputs.static-dir }}/
fi
- name: Generate site
id: generate
shell: bash
run: |
start_time=$(date +%s)
./bin/ailog build \
--content ${{ inputs.content-dir }} \
--output ${{ inputs.output-dir }} \
--templates ${{ inputs.template-dir }} \
--static ${{ inputs.static-dir }} \
--config ${{ inputs.config-file }}
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
- name: Display build summary
shell: bash
run: |
echo "✅ ailog build completed successfully"
echo "📁 Output directory: ${{ inputs.output-dir }}"
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
if [ -d "${{ inputs.output-dir }}" ]; then
echo "📄 Generated files:"
find ${{ inputs.output-dir }} -type f | head -10
fi

478
claude.md
View File

@@ -1,478 +0,0 @@
# エコシステム統合設計書
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
- **現実の反映**: 現実→ゲーム→現実の循環的影響
## システム構成図
```
存在子(ai) - 最小単位の意識
[ai.moji] 文字システム
[ai.os] + [ai.game device] ← 統合ハードウェア
├── ai.shell (Claude Code的機能)
├── ai.gpt (自律人格・記憶システム)
├── ai.log (AIと連携するブログシステム)
├── ai.ai (個人特化AI・心を読み取るAI)
├── ai.card (カードゲーム・iOS/Web/API)
└── ai.bot (分散SNS連携・カード配布)
[ai.verse] メタバース
├── world system (惑星型3D世界)
├── at system (atproto/分散SNS)
├── yui system (唯一性担保)
└── ai system (存在属性)
```
## 名前規則
名前規則は他のprojectと全て共通しています。exampleを示しますので、このルールに従ってください。
ここでは`ai.os`の場合の名前規則の例を記述します。
name: ai.os
- **[ "package", "code", "command" ]**: aios
- **[ "dir", "url" ]**: ai/os
- **[ "domain", "json" ]**: ai.os
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.os
{ "type": "os" }
```
```json
{
"ai": {
"os":{}
}
}
```
他のprojectも同じ名前規則を採用します。`ai.gpt`ならpackageは`aigpt`です。
## config(設定ファイル, env, 環境依存)
`config`を置く場所は統一されており、各projectの名前規則の`dir`項目を使用します。例えば、aiosの場合は`~/.config/syui/ai/os/`以下となります。pythonなどを使用する場合、`python -m venv`などでこのpackage config dirに環境を構築して実行するようにしてください。
domain形式を採用して、私は各projectを`git.syui.ai/ai`にhostしていますから、`~/.config/syui/ai`とします。
```sh
[syui.ai]
syui/ai
```
```sh
# example
~/.config/syui/ai
├── card
├── gpt
├── os
└── shell
```
## 各システム詳細
### ai.gpt - 自律的送信AI
**目的**: 関係性に基づく自発的コミュニケーション
**中核概念**:
- **人格**: 記憶(過去の発話)と関係性パラメータで構成
- **唯一性**: atproto accountとの1:1紐付け、改変不可能
- **自律送信**: 関係性が閾値を超えると送信機能が解禁
**技術構成**:
- `MemoryManager`: 完全ログ→AI要約→コア判定→選択的忘却
- `RelationshipTracker`: 時間減衰・日次制限付き関係性スコア
- `TransmissionController`: 閾値判定・送信トリガー
- `Persona`: AI運勢1-10ランダムによる人格変動
**実装仕様**:
```
- 言語: Python (fastapi_mcp)
- ストレージ: JSON/SQLite選択式
- インターフェース: Python CLI (click/typer)
- スケジューリング: cron-like自律処理
```
### ai.card - カードゲームシステム
**目的**: atproto基盤でのユーザーデータ主権カードゲーム
**現在の状況**:
- ai.botの機能として実装済み
- atproto accountでmentionすると1日1回カードを取得
- ai.api (MCP server予定) でユーザー管理
**移行計画**:
- **iOS移植**: Claudeが担当予定
- **データ保存**: atproto collection recordに保存ユーザーがデータを所有
- **不正防止**: OAuth 2.1 scope (実装待ち) + MCP serverで対応
- **画像ファイル**: Cloudflare Pagesが最適
**yui system適用**:
- カードの効果がアカウント固有
- 改ざん防止によるゲームバランス維持
- 将来的にai.verseとの統合で固有スキルと連動
### ai.ai - 心を読み取るAI
**目的**: 個人特化型AI・深層理解システム
**ai.gptとの関係**:
- ai.gpt → ai.ai: 自律送信AIから心理分析AIへの連携
- 関係性パラメータの深層分析
- ユーザーの思想コア部分の特定支援
### ai.verse - UEメタバース
**目的**: 現実反映型3D世界
**yui system実装**:
- キャラクター ↔ プレイヤー 1:1紐付け
- unique skill: そのプレイヤーのみ使用可能
- 他プレイヤーは同キャラでも同スキル使用不可
**統合要素**:
- ai.card: ゲーム内アイテムとしてのカード
- ai.gpt: NPCとしての自律AI人格
- atproto: ゲーム内プロフィール連携
## データフロー設計
### 唯一性担保の実装
```
現実の個人 → atproto account (DID) → ゲーム内avatar → 固有スキル
↑_______________________________| (現実の反映)
```
### AI駆動変換システム
```
遊び・創作活動 → ai.gpt分析 → 業務成果変換 → 企業価値創出
↑________________________| (Play-to-Work)
```
### カードゲーム・データ主権フロー
```
ユーザー → ai.bot mention → カード生成 → atproto collection → ユーザー所有
↑ ↓
← iOS app表示 ← ai.card API ←
```
## 技術スタック統合
### Core Infrastructure
- **OS**: Rust-based ai.os (Arch Linux base)
- **Container**: Docker image distribution
- **Identity**: atproto selfhost server + DID管理
- **AI**: fastapi_mcp server architecture
- **CLI**: Python unified (click/typer) - Rustから移行
### Game Engine Integration
- **Engine**: Unreal Engine (Blueprint)
- **Data**: atproto → UE → atproto sync
- **Avatar**: 分散SNS profile → 3D character
- **Streaming**: game screen = broadcast screen
### Mobile/Device
- **iOS**: ai.card移植 (Claude担当)
- **Hardware**: ai.game device (future)
- **Interface**: controller-first design
## 実装優先順位
### Phase 1: AI基盤強化 (現在進行)
- [ ] ai.gpt memory system完全実装
- 記憶の階層化(完全ログ→要約→コア→忘却)
- 関係性パラメータの時間減衰システム
- AI運勢による人格変動機能
- [ ] ai.card iOS移植
- atproto collection record連携
- MCP server化ai.api刷新
- [ ] fastapi_mcp統一基盤構築
### Phase 2: ゲーム統合
- [ ] ai.verse yui system実装
- unique skill機能
- atproto連携強化
- [ ] ai.gpt ↔ ai.ai連携機能
- [ ] 分散SNS ↔ ゲーム同期
### Phase 3: メタバース浸透
- [ ] VTuber配信機能統合
- [ ] Play-to-Work変換システム
- [ ] ai.game device prototype
## 将来的な連携構想
### システム間連携(現在は独立実装)
```
ai.gpt (自律送信) ←→ ai.ai (心理分析)
ai.card (iOS,Web,API) ←→ ai.verse (UEゲーム世界)
```
**共通基盤**: fastapi_mcp
**共通思想**: yui system現実の反映・唯一性担保
### データ改ざん防止戦略
- **短期**: MCP serverによる検証
- **中期**: OAuth 2.1 scope実装待ち
- **長期**: ブロックチェーン的整合性チェック
## AIコミュニケーション最適化
### プロジェクト要件定義テンプレート
```markdown
# [プロジェクト名] 要件定義
## 哲学的背景
- 存在子理論との関連:
- yui system適用範囲
- 現実反映の仕組み:
## 技術要件
- 使用技術fastapi_mcp統一
- atproto連携方法
- データ永続化方法:
## ユーザーストーリー
1. ユーザーが...すると
2. システムが...を実行し
3. 結果として...が実現される
## 成功指標
- 技術的:
- 哲学的(唯一性担保):
```
### Claude Code活用戦略
1. **小さく始める**: ai.gptのMCP機能拡張から
2. **段階的統合**: 各システムを個別に完成させてから統合
3. **哲学的一貫性**: 各実装でyui systemとの整合性を確認
4. **現実反映**: 実装がどう現実とゲームを繋ぐかを常に明記
## 開発上の留意点
### MCP Server設計指針
- 各AIgpt, card, ai, botは独立したMCPサーバー
- fastapi_mcp基盤で統一
- atproto DIDによる認証・認可
### 記憶・データ管理
- **ai.gpt**: 関係性の不可逆性重視
- **ai.card**: ユーザーデータ主権重視
- **ai.verse**: ゲーム世界の整合性重視
### 唯一性担保実装
- atproto accountとの1:1紐付け必須
- 改変不可能性をハッシュ・署名で保証
- 他システムでの再現不可能性を技術的に実現
## 継続的改善
- 各プロジェクトでこの設計書を参照
- 新機能追加時はyui systemとの整合性をチェック
- 他システムへの影響を事前評価
- Claude Code導入時の段階的移行計画
## ai.gpt深層設計思想
### 人格の不可逆性
- **関係性の破壊は修復不可能**: 現実の人間関係と同じ重み
- **記憶の選択的忘却**: 重要でない情報は忘れるが、コア記憶は永続
- **時間減衰**: すべてのパラメータは時間とともに自然減衰
### AI運勢システム
- 1-10のランダム値で日々の人格に変化
- 連続した幸運/不運による突破条件
- 環境要因としての人格形成
### 記憶の階層構造
1. **完全ログ**: すべての会話を記録
2. **AI要約**: 重要な部分を抽出して圧縮
3. **思想コア判定**: ユーザーの本質的な部分を特定
4. **選択的忘却**: 重要度の低い情報を段階的に削除
### 実装における重要な決定事項
- **言語統一**: Python (fastapi_mcp) で統一、CLIはclick/typer
- **データ形式**: JSON/SQLite選択式
- **認証**: atproto DIDによる唯一性担保
- **段階的実装**: まず会話→記憶→関係性→送信機能の順で実装
### 送信機能の段階的実装
- **Phase 1**: CLIでのprint出力現在
- **Phase 2**: atproto直接投稿
- **Phase 3**: ai.bot (Rust/seahorse) との連携
- **将来**: マルチチャネル対応SNS、Webhook等
## ai.gpt実装状況2025/01/06
### 完成した機能
- 階層的記憶システムMemoryManager
- 不可逆的関係性システムRelationshipTracker
- AI運勢システムFortuneSystem
- 統合人格システムPersona
- スケジューラー5種類のタスク
- MCP Server9種類のツール
- 設定管理(~/.config/aigpt/
- 全CLIコマンド実装
### 次の開発ポイント
- `ai_gpt/DEVELOPMENT_STATUS.md` を参照
- 自律送信: transmission.pyでatproto実装
- ai.bot連携: 新規bot_connector.py作成
- テスト: tests/ディレクトリ追加
## このprojectはai.log
このprojectは`ai.log`にあたります。
package, codeは`ailog`となります。
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.log
{
"type": "blog",
"text": "今はhugoでblogを作ってる。それをclaude codeでrustの静的ブログジェネレーターを作る。AI機能を付け加える。AI機能は具体的に記事の修正、情報の追加、lang:jaを自動翻訳してlang:en(page)を生成。アイが一言コメントするコメント欄の追加を行う。なお、コメント欄はatprotoと連携し、atprotoアカウントのoauthでログインして書き込める"
}
```
rustで静的ブログジェネレーターを作ります。参考になるのが`zola`です。
- https://github.com/getzola/zola
また、atprotoとの連携は`ai.card``atproto/oauth`の実装が参考になります。
- https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md
```json
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "Example atproto Browser App",
"client_uri": "https://example.com",
"logo_uri": "https://example.com/logo.png",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy",
"redirect_uris": ["https://example.com/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
```
```js
// package
import { Agent } from '@atproto/api'
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
async function main() {
const oauthClient = await BrowserOAuthClient.load({
clientId: '<YOUR_CLIENT_ID>',
handleResolver: 'https://bsky.social/',
})
// TO BE CONTINUED
}
document.addEventListener('DOMContentLoaded', main)
// client
const result = await oauthClient.init()
if (result) {
if ('state' in result) {
console.log('The user was just redirected back from the authorization page')
}
console.log(`The user is currently signed in as ${result.session.did}`)
}
const session = result?.session
// session
if (session) {
const agent = new Agent(session)
const fetchProfile = async () => {
const profile = await agent.getProfile({ actor: agent.did })
return profile.data
}
// Update the user interface
document.body.textContent = `Authenticated as ${agent.did}`
const profileBtn = document.createElement('button')
document.body.appendChild(profileBtn)
profileBtn.textContent = 'Fetch Profile'
profileBtn.onclick = async () => {
const profile = await fetchProfile()
outputPre.textContent = JSON.stringify(profile, null, 2)
}
const logoutBtn = document.createElement('button')
document.body.appendChild(logoutBtn)
logoutBtn.textContent = 'Logout'
logoutBtn.onclick = async () => {
await session.signOut()
window.location.reload()
}
const outputPre = document.createElement('pre')
document.body.appendChild(outputPre)
}
```
AIとの連携は`ai.gpt`をみてください。
- https://git.syui.ai/ai/gpt
`claude.md`があるので、`../gpt/claude.md`を読み込んでください。
### build deploy
主に`github-actions`, `cloudflare pages`を使用することを想定しています。
build, deploy, AIとの連携は記事をpushすると、自動で行われます。
```yml
// .github/workflows/gh-pages.yml
name: github pages
on:
push:
branches:
- main
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build
env:
TZ: "Asia/Tokyo"
run: |
hugo version
TZ=Asia/Tokyo hugo
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages
```
# footer
© syui

View File

@@ -1,18 +0,0 @@
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
ingress:
- hostname: log.syui.ai
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- hostname: ollama.syui.ai
service: http://localhost:11434
originRequest:
noHappyEyeballs: true
httpHostHeader: "localhost:11434"
# Cloudflare Accessを無効化する場合は以下をコメントアウト
# accessPolicy: bypass
- service: http_status:404

View File

@@ -0,0 +1,14 @@
# Test Post (Updated!)
This is a test blog post for ailog.
## Features
- atproto integration
- Static site generation
- at browser support
- Hash-based rkey (TID: 3mbvk36vj2k2y)
Let's see how it renders!
**Updated:** This post was updated to test the mapping.json feature.

View File

@@ -0,0 +1,36 @@
{
"lexicon": 1,
"id": "ai.syui.log.comment",
"defs": {
"main": {
"type": "record",
"description": "Record containing a comment.",
"key": "tid",
"record": {
"type": "object",
"required": ["content", "createdAt", "post"],
"properties": {
"content": {
"type": "string",
"maxLength": 100000,
"maxGraphemes": 10000,
"description": "The content of the comment."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this comment was originally created."
},
"parent": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
},
"post": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
}
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
{
"lexicon": 1,
"id": "ai.syui.log.post",
"defs": {
"main": {
"type": "record",
"description": "Record containing a blog post.",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "content", "createdAt"],
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The title of the post."
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000,
"description": "The content of the post."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
}
}
}
}
}
}

View File

@@ -1,31 +0,0 @@
[site]
title = "syui.ai"
description = "a blog powered by ailog"
base_url = "https://syui.ai"
language = "ja"
author = "syui"
[build]
highlight_code = true
highlight_theme = "Monokai"
minify = false
[ai]
enabled = true
auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:2b"
host = "https://ollama.syui.ai"
system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
collection_comment = "ai.syui.log"
collection_user = "ai.syui.log.user"
collection_chat = "ai.syui.log.chat"
bsky_api = "https://public.api.bsky.app"

View File

@@ -1,159 +0,0 @@
---
title: "静的サイトジェネレータを作った"
slug: "ailog"
date: "2025-06-12"
tags: ["blog", "rust", "mcp", "atp"]
language: ["ja", "en"]
---
rustで静的サイトジェネレータを作りました。。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。
## quick start
```sh
$ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog server my-blog
```
## install
```sh
$ cargo install --path .
---
$ export CARGO_HOME="$HOME/.cargo"
$ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog
```
## build deploy
```sh
$ cd my-blog
$ vim config.toml
$ ailog new test
$ vim content/posts/`date +"%Y-%m-%d"`.md
$ ailog build
# publicの中身をweb-serverにdeploy
$ cp -rf ./public/* ./web-server/root/
```
## atproto-comment-system
### example
```sh
$ cd ./oauth
$ npm i
$ npm run build
$ npm run preview
```
```sh
# Production environment variables
VITE_APP_HOST=https://example.com
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
```
これは`ailog oauth build my-blog``./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
```sh
$ ailog oauth build my-blog
```
### use
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
```html
<script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css">
<section class="comment-section"> <div id="comment-atproto"></div> </section>
```
ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。
```yml:cloudflared-config.yml
tunnel: ${hash}
credentials-file: ${path}.json
ingress:
- hostname: example.com
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- service: http_status:404
```
```sh
# tunnel list, dnsに登録が必要です
$ cloudflared tunnel list
$ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog stream server
```
このコマンドで`ai.syui.log`を`jetstream`から監視して、書き込みがあれば、管理者の`ai.syui.log.user`に記録され、そのuser-listに基づいて、コメント一覧を取得します。
つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverで`ailog stream server`を動かさなければいけません。
## ask-AI
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atprotoと組み合わせです。
## code syntax
```zsh:/path/to/test.zsh
# comment
d=${0:a:h}
```
```rust:/path/to/test.rs
// This is a comment
fn main() {
println!("Hello, world!");
}
```
```js:/path/to/test.js
// This is a comment
console.log("Hello, world!");
```

View File

@@ -1,62 +0,0 @@
---
title: "ブログを移行した"
slug: "blog"
date: 2025-06-14
tags: ["blog", "cloudflare", "github"]
draft: false
---
ブログを移行しました。
1. `gh-pages`から`cf-pages`への移行になります。
2. `hugo`からの移行で、自作の`ailog`でbuildしています。
3. 特徴としては、`atproto`, `AI`との連携です。
```yml:.github/workflows/cloudflare-pages.yml
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
- name: Build site with ailog
run: |
cd my-blog
../target/release/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
```

View File

@@ -1,14 +0,0 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "mock_x_coordinate_base64url",
"y": "mock_y_coordinate_base64url",
"d": "mock_private_key_base64url",
"use": "sig",
"kid": "ai-card-oauth-key-1",
"alg": "ES256"
}
]
}

View File

@@ -1,51 +0,0 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 1; mode=block
Permissions-Policy: camera=(), microphone=(), geolocation=()
# OAuth specific headers
/oauth/*
Access-Control-Allow-Origin: https://bsky.social
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
# Static assets caching
/assets/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Content-Type: text/css
Cache-Control: public, max-age=60
/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
/assets/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
# Ensure ES6 modules are served correctly
/assets/comment-atproto-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# All JS assets
/assets/*-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# CSS assets
/assets/*.css
Content-Type: text/css
Cache-Control: public, max-age=60
/posts/*
Cache-Control: public, max-age=3600
# Client metadata for OAuth
/client-metadata.json
Content-Type: application/json
Cache-Control: public, max-age=3600

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,24 +0,0 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
"policy_uri": "https://syui.ai/privacy",
"redirect_uris": [
"https://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

View File

@@ -1,894 +0,0 @@
/* Theme Colors */
:root {
--theme-color: #f40;
--ai-color: #ff7;
--white: #fff;
--light-white: #f5f5f5;
--dark-white: #d1d9e0;
--light-gray: #f6f8fa;
--dark-gray: #666;
--background: #fff;
}
/* Base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1f2328;
background-color: #ffffff;
font-size: 16px;
}
/* Buttons */
button {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
}
/* Links */
a:any-link {
color: var(--theme-color);
text-decoration-line: unset;
cursor: pointer;
}
a:hover {
color: var(--theme-color);
opacity: 0.8;
}
/* Override link color for specific buttons */
a.view-markdown,
a.view-markdown:any-link {
color: #ffffff !important;
text-decoration: none !important;
}
/* Layout */
.container {
min-height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr auto;
grid-template-areas:
"header"
"ask-ai"
"main"
"footer";
}
/* Header */
.main-header {
grid-area: header;
background: #ffffff;
border-bottom: 1px solid #d1d9e0;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1000px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
position: relative;
}
.site-title {
color: var(--theme-color);
text-decoration: none;
font-size: 20px;
font-weight: 600;
}
.logo {
grid-column: 2;
padding: 20px;
}
.logo .likeButton {
height: 60px;
width: auto;
cursor: pointer;
background: transparent;
border: none;
}
.header-actions {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
}
/* Ask AI Button */
.ask-ai-btn {
background: var(--theme-color);
color: var(--white);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s;
margin: 0;
}
.ask-ai-btn:hover {
filter: brightness(1.1);
}
.ai-icon {
font-size: 16px;
width: 20px;
height: 20px;
color: var(--ai-color);
display: inline-block;
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Ask AI Panel */
.ask-ai-panel {
grid-area: ask-ai;
background: #f6f8fa;
border-bottom: 1px solid #d1d9e0;
padding: 24px;
}
.ask-ai-content {
max-width: 1000px;
margin: 0 auto;
}
.ask-ai-form {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.ask-ai-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
font-size: 14px;
}
.auth-check {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 6px;
padding: 16px;
text-align: center;
margin-bottom: 16px;
}
/* Main Content */
.main-content {
grid-area: main;
max-width: 1000px;
margin: 0 auto;
padding: 24px;
width: 100%;
}
@media (max-width: 1000px) {
.main-content {
padding: 20px;
max-width: 100%;
}
}
/* Timeline */
.timeline-container {
max-width: 600px;
margin: 0 auto;
}
.timeline-header h2 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 24px;
}
.timeline-feed {
display: flex;
flex-direction: column;
gap: 24px;
}
.timeline-post {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 20px;
transition: box-shadow 0.2s;
}
.timeline-post:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-title a {
color: #1f2328;
text-decoration: none;
font-size: 18px;
font-weight: 600;
}
.post-title a:hover {
color: var(--theme-color);
}
.post-date {
color: #656d76;
font-size: 14px;
}
.post-excerpt {
color: #656d76;
margin: 16px 0;
line-height: 1.5;
}
.post-actions {
display: flex;
gap: 16px;
align-items: center;
}
.read-more {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.read-more:hover {
text-decoration: underline;
}
.view-markdown, .view-translation {
color: #656d76;
text-decoration: none;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.view-markdown {
background: var(--theme-color) !important;
color: #ffffff !important;
border: 1px solid var(--theme-color);
}
.view-markdown:hover {
filter: brightness(1.1);
color: #ffffff !important;
background: var(--theme-color) !important;
}
.view-translation:hover {
background: #f6f8fa;
}
.post-meta {
display: flex;
gap: 12px;
align-items: center;
}
.post-lang {
background: #f6f8fa;
color: #656d76;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
/* Article */
.article-container {
display: grid;
grid-template-columns: 1fr 240px;
gap: 40px;
max-width: 1000px;
margin: 0 auto;
}
.article-meta {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.article-date {
color: #656d76;
font-size: 14px;
}
.article-lang {
background: #f6f8fa;
color: #656d76;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.article-actions {
display: flex;
gap: 12px;
}
.action-btn {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
padding: 6px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
transition: all 0.2s;
}
.action-btn:hover {
background: #f6f8fa;
border-color: var(--theme-color);
}
.markdown-btn {
background: var(--dark-white);
color: var(--white);
border-color: var(--white);
}
.markdown-btn:link,
.markdown-btn:visited {
color: var(--white) !important;
}
.markdown-btn:hover {
filter: brightness(0.9);
color: var(--theme-color) !important;
border-color: var(--white);
}
/* Sidebar styles */
.article-sidebar {
position: sticky;
top: 100px;
height: fit-content;
}
.toc {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
}
.toc h3 {
color: #1f2328;
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.toc-list {
list-style: none;
padding: 0;
}
.toc-item {
margin-bottom: 8px;
}
/* Hierarchy indentation for TOC */
.toc-item.toc-h1 .toc-link {
padding-left: 0;
font-weight: 600;
font-size: 14px;
}
.toc-item.toc-h2 .toc-link {
padding-left: 0;
font-weight: 500;
font-size: 14px;
}
.toc-item.toc-h3 .toc-link {
padding-left: 16px;
font-weight: 400;
font-size: 13px;
}
.toc-item.toc-h4 .toc-link {
padding-left: 32px;
font-weight: 400;
font-size: 12px;
opacity: 0.9;
}
.toc-item.toc-h5 .toc-link {
padding-left: 48px;
font-weight: 400;
font-size: 12px;
opacity: 0.8;
}
.toc-item.toc-h6 .toc-link {
padding-left: 64px;
font-weight: 400;
font-size: 11px;
opacity: 0.7;
}
.toc-link {
color: #656d76;
text-decoration: none;
font-size: 14px;
line-height: 1.4;
display: block;
padding: 4px 0;
transition: color 0.2s;
}
.toc-link:hover {
color: var(--theme-color);
}
.article-title {
color: #1f2328;
font-size: 32px;
font-weight: 600;
margin-bottom: 16px;
line-height: 1.25;
}
.article-body {
color: #1f2328;
line-height: 1.6;
}
.article-body h1, .article-body h2, .article-body h3 {
color: #1f2328;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.article-body p {
margin-bottom: 16px;
}
.article-body ol, .article-body ul {
margin: 16px 0;
padding-left: 24px;
}
.article-body li {
margin-bottom: 8px;
line-height: 1.6;
}
.article-body ol li {
list-style-type: decimal;
}
.article-body ul li {
list-style-type: disc;
}
.article-body pre {
background: #1B1D1E !important;
border: 1px solid #3E3D32;
border-radius: 8px;
padding: 0;
overflow: hidden;
margin: 16px 0;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
position: relative;
}
/* File name display for code blocks */
.article-body pre[data-filename]::before {
content: attr(data-filename);
position: absolute;
top: 0;
right: 0;
background: #2D2D30;
color: #CCCCCC;
padding: 4px 12px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
border-bottom-left-radius: 4px;
border: 1px solid #3E3D32;
border-top: none;
border-right: none;
z-index: 1;
}
.article-body pre code {
display: block;
background: none !important;
padding: 30px 16px;
color: #F8F8F2 !important;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
overflow-x: auto;
line-height: 1.4;
}
/* Inline code (not in pre blocks) */
.article-body code {
background: var(--light-white);
color: var(--dark-gray);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
font-size: 13px;
}
/* Molokai syntax highlighting */
.article-body pre code .hljs-keyword { color: #F92672; }
.article-body pre code .hljs-string { color: #E6DB74; }
.article-body pre code .hljs-comment { color: #88846F; font-style: italic; }
.article-body pre code .hljs-number { color: #AE81FF; }
.article-body pre code .hljs-variable { color: #FD971F; }
.article-body pre code .hljs-function { color: #A6E22E; }
.article-body pre code .hljs-tag { color: #F92672; }
.article-body pre code .hljs-attr { color: #A6E22E; }
.article-body pre code .hljs-value { color: #E6DB74; }
/* Fix inline span colors in code blocks */
.article-body pre code span[style*="color:#8fa1b3"] { color: #AE81FF !important; } /* $ prompt */
.article-body pre code span[style*="color:#c0c5ce"] { color: #F8F8F2 !important; } /* commands */
.article-body pre code span[style*="color:#75715E"] { color: #88846F !important; } /* real comments */
/* Shell/Bash specific fixes */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Default to white for variables and code */
}
/* Comments in shell scripts - lines that contain # followed by text */
.article-body pre code span[style*="color:#65737e"]:has-text("#") {
color: #88846F !important;
}
/* Alternative approach - check content */
.article-body pre code {
/* Reset all gray colored text to white by default */
}
.article-body pre code span[style*="color:#65737e"] {
/* Check if the content starts with # */
color: #F8F8F2 !important;
}
/* Override for actual comments - this is a workaround */
.article-body pre code span[style*="color:#65737e"]:first-child:before {
content: attr(data-comment);
}
/* Detect comments by position and content pattern */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Environment variables = white */
}
/* Only style as comment if the line actually starts with # */
.article-body pre code > span:first-child[style*="color:#65737e"] {
color: #88846F !important; /* Real comments = gray */
}
/* Footer */
.main-footer {
grid-area: footer;
background: var(--light-white);
border-top: 1px solid #d1d9e0;
padding: 32px 24px;
text-align: center;
}
.footer-social {
display: flex;
justify-content: center;
gap: 24px;
margin: 40px;
}
.footer-social a {
color: var(--dark-gray) !important;
text-decoration: none !important;
font-size: 16px;
transition: all 0.2s ease;
}
.footer-social a:hover {
color: var(--theme-color) !important;
opacity: 0.8;
}
.main-footer p {
color: #656d76;
font-size: 14px;
margin: 0;
}
/* Chat Messages */
.chat-message.comment-style {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.chat-message.ai-message.comment-style {
border-left: 4px solid var(--ai-color);
background: #faf8ff;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.message-header .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f6f8fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 1px solid #d1d9e0;
}
.display-name {
font-weight: 600;
color: #1f2328;
font-size: 14px;
}
.handle {
color: #656d76;
font-size: 13px;
}
.timestamp {
color: #656d76;
font-size: 12px;
}
.message-content {
color: #1f2328;
line-height: 1.5;
white-space: pre-wrap;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
/* Loading Animation */
.ai-loading-simple {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
background: linear-gradient(90deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 20px;
margin: 8px 0;
font-size: 14px;
color: #495057;
border: 1px solid #dee2e6;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Comment System Styles */
.comment-section {
max-width: 800px;
margin: 0 auto;
margin-top: 48px;
padding-top: 32px;
}
.comment-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.comment-section h3 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
margin-bottom: 32px;
text-align: center;
}
/* OAuth Comment System - Hide on homepage by default, show on post pages */
.timeline-container .comment-section {
display: block; /* Show on homepage */
}
.timeline-container .comment-section .comments-list > :nth-child(n+6) {
display: none; /* Hide comments after the 5th one */
}
.article-container .comment-section,
.article-content + .comment-section {
display: block; /* Show all comments on post pages */
}
/* Responsive */
@media (max-width: 1000px) {
.article-container {
grid-template-columns: 1fr;
gap: 24px;
max-width: 100%;
padding: 0;
margin: 0;
}
}
@media (max-width: 1000px) {
.main-header {
padding: 12px 0;
}
.header-content {
max-width: 100%;
padding: 0 20px;
grid-template-columns: auto 1fr auto;
gap: 0;
}
/* Hide site title text on mobile */
.site-title {
display: none;
}
/* Left align logo on mobile */
.logo {
grid-column: 1;
justify-self: left;
padding: 5px;
display: flex;
justify-content: flex-start;
align-items: center;
}
/* Reduce logo size on mobile */
.logo .likeButton {
width: 40pt;
height: 40pt;
}
/* Position AI button on the right */
.header-actions {
grid-column: 3;
justify-self: end;
}
/* Ask AI button mobile style */
.ask-ai-btn {
padding: 8px;
min-width: 40px;
justify-content: center;
font-size: 0;
gap: 0;
}
.ask-ai-btn .ai-icon {
margin: 0;
font-size: 16px;
}
.ask-ai-panel {
padding: 16px;
}
/* Article content mobile optimization */
.article-body {
overflow-x: hidden;
}
.article-body pre {
margin: 16px 0;
border-radius: 4px;
max-width: 100%;
overflow-x: auto;
}
.article-body pre code {
padding: 20px 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
.article-body code {
word-break: break-all;
}
.ask-ai-form {
flex-direction: column;
}
.timeline-container {
max-width: 100%;
padding: 0;
}
.timeline-post {
padding: 16px;
}
.article-title {
font-size: 24px;
}
.message-header .avatar {
width: 32px;
height: 32px;
font-size: 16px;
}
/* Center content on mobile */
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
padding: 0;
}
}

View File

@@ -1,342 +0,0 @@
/* SVG Animation Package - Dependency-free standalone package
* Based on svg-animation-particle-circle.css
* Theme color integration with CSS variables
*/
/* Theme-based color variables for particles */
:root {
--particle-color-1: #f40; /* theme-color base */
--particle-color-2: #f50; /* theme-color +0.1 brightness */
--particle-color-3: #f60; /* theme-color +0.2 brightness */
--particle-color-4: #f70; /* theme-color +0.3 brightness */
--particle-color-5: #f80; /* theme-color +0.4 brightness */
--explosion-color: #f30; /* theme-color -0.1 brightness */
--syui-color: #f40; /* main theme color */
}
/* Core SVG button setup */
.likeButton {
cursor: pointer;
display: inline-block;
}
/* Remove debug animation and restore hover functionality */
.likeButton .border {
fill: white;
}
/* Explosion circle - initially hidden */
.likeButton .explosion {
transform-origin: center center;
transform: scale(1);
stroke: var(--explosion-color);
fill: none;
opacity: 0;
stroke-width: 1;
}
/* Particle layer - initially hidden */
.likeButton .particleLayer {
opacity: 0;
transform: scale(0); /* Ensure particles start hidden */
}
.likeButton .particleLayer circle {
opacity: 0;
transform-origin: center center; /* Fixed from 250px 250px */
transform: scale(0);
}
/* Syui logo - main animation target */
.likeButton .syui {
fill: var(--syui-color);
transform: scale(1);
transform-origin: center center;
}
/* Hover trigger - replaces jQuery */
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards;
}
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards;
}
/* Individual particle animations */
.likeButton:hover .particleLayer circle:nth-child(1) {
animation: particleAnimate1 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(2) {
animation: particleAnimate2 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(3) {
animation: particleAnimate3 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(4) {
animation: particleAnimate4 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(5) {
animation: particleAnimate5 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(6) {
animation: particleAnimate6 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(7) {
animation: particleAnimate7 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(8) {
animation: particleAnimate8 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(9) {
animation: particleAnimate9 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(10) {
animation: particleAnimate10 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(11) {
animation: particleAnimate11 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(12) {
animation: particleAnimate12 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(13) {
animation: particleAnimate13 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(14) {
animation: particleAnimate14 800ms;
animation-fill-mode: forwards;
}
/* Keyframe animations */
@keyframes explosionAnime {
0% {
opacity: 0;
transform: scale(0.01);
}
1% {
opacity: 1;
transform: scale(0.01);
}
5% {
stroke-width: 200;
}
20% {
stroke-width: 300;
}
50% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 1;
}
50.1% {
stroke-width: 0;
}
100% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 0;
}
}
@keyframes particleLayerAnime {
0% {
transform: translate(0, 0);
opacity: 0;
}
30% {
opacity: 0;
}
31% {
opacity: 1;
}
60% {
transform: translate(0, 0);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(0, -20px);
}
}
/* Syui Deluxe Animation - Based on 2019 blog post */
@keyframes syuiDeluxeAnime {
0% {
fill: var(--syui-color);
transform: scale(1) translate(0%, 0%);
}
40% {
fill: color-mix(in srgb, var(--syui-color) 40%, transparent);
transform: scale(1, 0.9) translate(-9%, 9%);
}
50% {
fill: color-mix(in srgb, var(--syui-color) 70%, transparent);
transform: scale(1, 0.9) translate(-7%, 7%);
}
60% {
transform: scale(1) translate(-7%, 7%);
}
70% {
transform: scale(1.04) translate(-5%, 5%);
}
80% {
fill: color-mix(in srgb, var(--syui-color) 60%, transparent);
transform: scale(1.04) translate(-5%, 5%);
}
90% {
fill: var(--particle-color-5); /* 爆発の閃光 */
transform: scale(1) translate(0%);
}
100% {
fill: var(--syui-color);
transform: scale(1, 1) translate(0%, 0%);
}
}
/* Individual particle animations */
@keyframes particleAnimate1 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-16px, -59px); }
90% { transform: translate(-16px, -59px); }
100% { opacity: 1; transform: translate(-16px, -59px); }
}
@keyframes particleAnimate2 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(41px, 43px); }
90% { transform: translate(41px, 43px); }
100% { opacity: 1; transform: translate(41px, 43px); }
}
@keyframes particleAnimate3 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(50px, -48px); }
90% { transform: translate(50px, -48px); }
100% { opacity: 1; transform: translate(50px, -48px); }
}
@keyframes particleAnimate4 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 36px); }
90% { transform: translate(-39px, 36px); }
100% { opacity: 1; transform: translate(-39px, 36px); }
}
@keyframes particleAnimate5 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 32px); }
90% { transform: translate(-39px, 32px); }
100% { opacity: 1; transform: translate(-39px, 32px); }
}
@keyframes particleAnimate6 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(48px, 6px); }
90% { transform: translate(48px, 6px); }
100% { opacity: 1; transform: translate(48px, 6px); }
}
@keyframes particleAnimate7 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-69px, -36px); }
90% { transform: translate(-69px, -36px); }
100% { opacity: 1; transform: translate(-69px, -36px); }
}
@keyframes particleAnimate8 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-12px, -52px); }
90% { transform: translate(-12px, -52px); }
100% { opacity: 1; transform: translate(-12px, -52px); }
}
@keyframes particleAnimate9 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-43px, -21px); }
90% { transform: translate(-43px, -21px); }
100% { opacity: 1; transform: translate(-43px, -21px); }
}
@keyframes particleAnimate10 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 47px); }
90% { transform: translate(-10px, 47px); }
100% { opacity: 1; transform: translate(-10px, 47px); }
}
@keyframes particleAnimate11 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(66px, -9px); }
90% { transform: translate(66px, -9px); }
100% { opacity: 1; transform: translate(66px, -9px); }
}
@keyframes particleAnimate12 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(40px, -45px); }
90% { transform: translate(40px, -45px); }
100% { opacity: 1; transform: translate(40px, -45px); }
}
@keyframes particleAnimate13 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(29px, 24px); }
90% { transform: translate(29px, 24px); }
100% { opacity: 1; transform: translate(29px, 24px); }
}
@keyframes particleAnimate14 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 50px); }
90% { transform: translate(-10px, 50px); }
100% { opacity: 1; transform: translate(-10px, 50px); }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,22 +0,0 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

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

View File

@@ -1,360 +0,0 @@
/**
* Ask AI functionality - Pure JavaScript, no jQuery dependency
*/
class AskAI {
constructor() {
this.isReady = false;
this.aiProfile = null;
this.init();
}
init() {
this.setupEventListeners();
this.checkAuthOnLoad();
}
setupEventListeners() {
// Listen for AI ready signal
window.addEventListener('aiChatReady', () => {
this.isReady = true;
console.log('AI Chat is ready');
});
// Listen for AI profile updates
window.addEventListener('aiProfileLoaded', (event) => {
this.aiProfile = event.detail;
console.log('AI profile loaded:', this.aiProfile);
this.updateButton();
});
// Listen for AI responses
window.addEventListener('aiResponseReceived', (event) => {
this.handleAIResponse(event.detail);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hide();
}
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
e.preventDefault();
this.ask();
}
});
// Monitor authentication changes
this.observeAuth();
}
toggle() {
const panel = document.getElementById('askAiPanel');
const isVisible = panel.style.display !== 'none';
if (isVisible) {
this.hide();
} else {
this.show();
}
}
show() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'block';
this.checkAuth();
}
hide() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'none';
}
checkAuth() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
const authCheck = document.getElementById('authCheck');
const chatForm = document.getElementById('chatForm');
const chatHistory = document.getElementById('chatHistory');
if (isAuthenticated) {
authCheck.style.display = 'none';
chatForm.style.display = 'block';
chatHistory.style.display = 'block';
if (chatHistory.children.length === 0) {
this.showGreeting();
}
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
authCheck.style.display = 'block';
chatForm.style.display = 'none';
chatHistory.style.display = 'none';
}
}
checkAuthOnLoad() {
setTimeout(() => {
this.checkAuth();
}, 500);
}
observeAuth() {
const observer = new MutationObserver(() => {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length > 0) {
this.checkAuth();
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
updateButton() {
const button = document.getElementById('askAiButton');
if (this.aiProfile && this.aiProfile.displayName) {
const textNode = button.childNodes[2];
if (textNode) {
textNode.textContent = this.aiProfile.displayName;
}
}
}
showGreeting() {
if (!this.aiProfile) return;
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = this.aiProfile.avatar
? `<img src="${this.aiProfile.avatar}" alt="${this.aiProfile.displayName}" class="profile-avatar">`
: '🤖';
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${this.aiProfile.displayName}</div>
<div class="handle">@${this.aiProfile.handle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
</div>
`;
chatHistory.appendChild(greetingDiv);
}
async ask() {
const question = document.getElementById('aiQuestion').value;
const chatHistory = document.getElementById('chatHistory');
const askButton = document.getElementById('askButton');
if (!question.trim()) return;
// Wait for AI to be ready
if (!this.isReady) {
await this.waitForReady();
}
// Disable button
askButton.disabled = true;
askButton.textContent = 'Posting...';
try {
// Add user message
this.addUserMessage(question);
// Clear input
document.getElementById('aiQuestion').value = '';
// Show loading
this.showLoading();
// Post question
const event = new CustomEvent('postAIQuestion', {
detail: { question: question }
});
window.dispatchEvent(event);
} catch (error) {
this.showError('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
}
waitForReady() {
return new Promise(resolve => {
const checkReady = setInterval(() => {
if (this.isReady) {
clearInterval(checkReady);
resolve();
}
}, 100);
});
}
addUserMessage(question) {
const chatHistory = document.getElementById('chatHistory');
const userSection = document.querySelector('.user-section');
let userAvatar = '👤';
let userDisplay = 'You';
let userHandle = 'user';
if (userSection) {
const avatarImg = userSection.querySelector('.user-avatar');
const displayName = userSection.querySelector('.user-display-name');
const handle = userSection.querySelector('.user-handle');
if (avatarImg && avatarImg.src) {
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
}
if (displayName?.textContent) {
userDisplay = displayName.textContent;
}
if (handle?.textContent) {
userHandle = handle.textContent.replace('@', '');
}
}
const questionDiv = document.createElement('div');
questionDiv.className = 'chat-message user-message comment-style';
questionDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${userAvatar}</div>
<div class="user-info">
<div class="display-name">${userDisplay}</div>
<div class="handle">@${userHandle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${question}</div>
`;
chatHistory.appendChild(questionDiv);
}
showLoading() {
const chatHistory = document.getElementById('chatHistory');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'ai-loading-simple';
loadingDiv.innerHTML = `
<i class="fas fa-robot"></i>
<span>考えています</span>
<i class="fas fa-spinner fa-spin"></i>
`;
chatHistory.appendChild(loadingDiv);
}
showError(message) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
const errorDiv = document.createElement('div');
errorDiv.className = 'chat-message error-message comment-style';
errorDiv.innerHTML = `
<div class="message-header">
<div class="avatar">⚠️</div>
<div class="user-info">
<div class="display-name">System</div>
<div class="handle">@system</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${message}</div>
`;
chatHistory.appendChild(errorDiv);
}
removeLoading() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
handleAIResponse(responseData) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
const aiProfile = responseData.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing');
return;
}
const timestamp = new Date(responseData.timestamp || Date.now());
const avatarElement = aiProfile.avatar
? `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`
: '🤖';
const answerDiv = document.createElement('div');
answerDiv.className = 'chat-message ai-message comment-style';
answerDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfile.displayName}</div>
<div class="handle">@${aiProfile.handle}</div>
<div class="timestamp">${timestamp.toLocaleString()}</div>
</div>
</div>
<div class="message-content">${responseData.answer}</div>
`;
chatHistory.appendChild(answerDiv);
// Limit chat history
this.limitChatHistory();
}
limitChatHistory() {
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length > 10) {
chatHistory.removeChild(chatHistory.children[0]);
if (chatHistory.children.length > 0) {
chatHistory.removeChild(chatHistory.children[0]);
}
}
}
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
try {
window.askAIInstance = new AskAI();
console.log('Ask AI initialized successfully');
} catch (error) {
console.error('Failed to initialize Ask AI:', error);
}
});
// Global function for onclick
window.AskAI = {
toggle: function() {
console.log('AskAI.toggle called');
if (window.askAIInstance) {
window.askAIInstance.toggle();
} else {
console.error('Ask AI instance not available');
}
},
ask: function() {
console.log('AskAI.ask called');
if (window.askAIInstance) {
window.askAIInstance.ask();
} else {
console.error('Ask AI instance not available');
}
}
};

View File

@@ -1,94 +0,0 @@
/**
* Theme and visual effects - Pure CSS animations, no jQuery
*/
class Theme {
constructor() {
this.init();
}
init() {
this.setupParticleColors();
this.setupLogoAnimations();
}
setupParticleColors() {
// Dynamic particle colors based on theme
const style = document.createElement('style');
style.textContent = `
/* Dynamic particle colors based on theme */
.likeButton .particleLayer circle:nth-child(1),
.likeButton .particleLayer circle:nth-child(2) {
fill: var(--particle-color-1) !important;
}
.likeButton .particleLayer circle:nth-child(3),
.likeButton .particleLayer circle:nth-child(4) {
fill: var(--particle-color-2) !important;
}
.likeButton .particleLayer circle:nth-child(5),
.likeButton .particleLayer circle:nth-child(6),
.likeButton .particleLayer circle:nth-child(7) {
fill: var(--particle-color-3) !important;
}
.likeButton .particleLayer circle:nth-child(8),
.likeButton .particleLayer circle:nth-child(9),
.likeButton .particleLayer circle:nth-child(10) {
fill: var(--particle-color-4) !important;
}
.likeButton .particleLayer circle:nth-child(11),
.likeButton .particleLayer circle:nth-child(12),
.likeButton .particleLayer circle:nth-child(13),
.likeButton .particleLayer circle:nth-child(14) {
fill: var(--particle-color-5) !important;
}
/* Reset initial animations but allow hover */
.likeButton .syui {
animation: none;
}
.likeButton .particleLayer {
animation: none;
}
.likeButton .explosion {
animation: none;
}
/* Enable hover animations from package */
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards !important;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards !important;
}
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards !important;
}
/* Logo positioning */
.logo .likeButton {
background: transparent !important;
display: block;
}
`;
document.head.appendChild(style);
}
setupLogoAnimations() {
// Pure CSS animations are handled by the svg-animation-package.css
// This method is reserved for any future JavaScript-based enhancements
console.log('Logo animations initialized (CSS-based)');
}
}
// Initialize theme when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Theme();
});

View File

@@ -1,165 +0,0 @@
Fonticons, Inc. (https://fontawesome.com)
--------------------------------------------------------------------------------
Font Awesome Free License
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
--------------------------------------------------------------------------------
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
The Font Awesome Free download is licensed under a Creative Commons
Attribution 4.0 International License and applies to all icons packaged
as SVG and JS file types.
--------------------------------------------------------------------------------
# Fonts: SIL OFL 1.1 License
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
with Reserved Font Name: "Font Awesome".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
--------------------------------------------------------------------------------
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
Copyright 2024 Fonticons, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
--------------------------------------------------------------------------------
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}

View File

@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,99 +0,0 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?mxezzh');
src: url('fonts/icomoon.eot?mxezzh#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?mxezzh') format('truetype'),
url('fonts/icomoon.woff?mxezzh') format('woff'),
url('fonts/icomoon.svg?mxezzh#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-git:before {
content: "\e901";
}
.icon-cube:before {
content: "\e900";
}
.icon-game:before {
content: "\e9d5";
}
.icon-card:before {
content: "\e9d6";
}
.icon-book:before {
content: "\e9d7";
}
.icon-git1:before {
content: "\e9d3";
}
.icon-moji_a:before {
content: "\e9c3";
}
.icon-archlinux:before {
content: "\e9c4";
}
.icon-archlinuxjp:before {
content: "\e9c5";
}
.icon-syui:before {
content: "\e9c6";
}
.icon-phoenix-power:before {
content: "\e9c7";
}
.icon-phoenix-world:before {
content: "\e9c8";
}
.icon-power:before {
content: "\e9c9";
}
.icon-phoenix:before {
content: "\e9ca";
}
.icon-honeycomb:before {
content: "\e9cb";
}
.icon-ai:before {
content: "\e9cc";
}
.icon-robot:before {
content: "\e9cd";
}
.icon-sandar:before {
content: "\e9ce";
}
.icon-moon:before {
content: "\e9cf";
}
.icon-home:before {
content: "\e9d0";
}
.icon-cloud:before {
content: "\e9d1";
}
.icon-api:before {
content: "\e9d2";
}
.icon-aibadge:before {
content: "\ebf8";
}
.icon-aiterm:before {
content: "\ebf7";
}

View File

@@ -1,24 +0,0 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="{{ config.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.title }}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
{% include "oauth-assets.html" %}
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header class="main-header">
<div class="header-content">
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
<div class="logo">
<a href="/">
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
</g>
</svg>
</a>
</div>
<div class="header-actions">
<button class="ask-ai-btn" onclick="AskAI.toggle()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
</button>
</div>
</div>
</header>
<!-- Ask AI Panel -->
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p>
</div>
<div id="chatForm" class="ask-ai-form" style="display: none;">
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
<button onclick="AskAI.ask()" id="askButton">Ask</button>
</div>
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% block sidebar %}{% endblock %}
</div>
<footer class="main-footer">
<div class="footer-social">
<a href="https://web.syu.is/@syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
</body>
</html>

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="timeline-container">
<div class="timeline-feed">
{% for post in posts %}
<article class="timeline-post">
<div class="post-header">
<div class="post-meta">
<time class="post-date">{{ post.date }}</time>
{% if post.language %}
<span class="post-lang">{{ post.language }}</span>
{% endif %}
</div>
</div>
<div class="post-content">
<h3 class="post-title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-actions">
<a href="{{ post.url }}" class="read-more">Read more</a>
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">.md</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
<!-- OAuth Comment System -->
<section class="comment-section">
<div id="comment-atproto"></div>
</section>
{% if posts|length == 0 %}
<div class="empty-state">
<p>No posts yet. Start writing!</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

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

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -1,93 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<div id="comment-atproto"></div>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
<script>
// Generate table of contents
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
generateTableOfContents();
});
</script>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@@ -1,27 +0,0 @@
# Production environment variables
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
AILOG_COLLECTION_CHAT=ai.syui.log.chat
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:2b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ai.card</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0a0a0a;
color: #ffffff;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,31 +0,0 @@
{
"name": "aicard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --mode development",
"build": "vite build --mode production",
"build:dev": "vite build --mode development",
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/did": "^0.1.5",
"@atproto/identity": "^0.4.8",
"@atproto/oauth-client-browser": "^0.3.19",
"@atproto/xrpc": "^0.7.0",
"axios": "^1.6.2",
"framer-motion": "^10.16.16",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.6.1"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -1,14 +0,0 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "mock_x_coordinate_base64url",
"y": "mock_y_coordinate_base64url",
"d": "mock_private_key_base64url",
"use": "sig",
"kid": "ai-card-oauth-key-1",
"alg": "ES256"
}
]
}

View File

@@ -1,24 +0,0 @@
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_uri": "https://log.syui.ai",
"logo_uri": "https://log.syui.ai/favicon.ico",
"tos_uri": "https://log.syui.ai/terms",
"policy_uri": "https://log.syui.ai/privacy",
"redirect_uris": [
"https://log.syui.ai/oauth/callback",
"https://log.syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

View File

@@ -1,881 +0,0 @@
/* Theme Colors */
:root {
--theme-color: #FF4500;
--white: #fff;
--light-gray: #aaa;
--dark-gray: #666;
--background: #fff;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
color: var(--dark-gray);
}
.app-header {
text-align: center;
padding: 40px 20px;
border-bottom: 1px solid #e9ecef;
position: relative;
}
.app-nav {
display: flex;
justify-content: center;
gap: 8px;
padding: 20px;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid #e9ecef;
margin-bottom: 40px;
}
.nav-button {
padding: 12px 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
color: #6c757d;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.nav-button:hover {
background: rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
color: #495057;
}
.nav-button.active {
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
}
.nav-button.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
}
.app-header h1 {
font-size: 48px;
margin: 0;
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-header p {
color: #6c757d;
margin-top: 10px;
}
.user-info {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 15px;
}
.user-handle {
color: #495057;
font-weight: bold;
background: rgba(102, 126, 234, 0.1);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #dee2e6;
}
.login-button,
.logout-button,
.backup-button,
.token-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
margin-left: 8px;
}
.login-button {
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
}
.backup-button {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: 1px solid #28a745;
}
.token-button {
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
color: white;
border: 1px solid #ffc107;
}
.logout-button {
background: rgba(108, 117, 125, 0.1);
color: #495057;
border: 1px solid #dee2e6;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.backup-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
.token-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
}
.logout-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
background: rgba(108, 117, 125, 0.2);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 24px;
color: #667eea;
}
.app-main {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
@media (max-width: 1000px) {
.app .app-main {
padding: 0px !important;
}
}
.gacha-section {
text-align: center;
margin-bottom: 60px;
}
.gacha-section h2 {
font-size: 32px;
margin-bottom: 30px;
}
.gacha-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.gacha-button {
padding: 20px 40px;
font-size: 18px;
font-weight: bold;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.gacha-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.gacha-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gacha-button-premium {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
position: relative;
overflow: hidden;
}
.gacha-button-premium::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.2) 50%,
transparent 70%
);
animation: shimmer 3s infinite;
}
.collection-section h2 {
font-size: 32px;
text-align: center;
margin-bottom: 30px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 30px;
justify-items: center;
}
.empty-message {
text-align: center;
color: #6c757d;
font-size: 18px;
margin-top: 40px;
}
.error {
color: #ff4757;
text-align: center;
margin-top: 20px;
}
@keyframes shimmer {
0% { transform: translateX(-100%) rotate(45deg); }
100% { transform: translateX(100%) rotate(45deg); }
}
/* Comment System Styles */
.comment-section {
max-width: 800px;
margin: 0 auto;
/* padding: 20px; - removed to avoid double padding */
}
.auth-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
text-align: center;
}
.atproto-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-bottom: 15px;
transition: all 0.3s ease;
}
.atproto-button:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.username-input-section {
margin: 15px 0;
}
.handle-input {
width: 300px;
max-width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
text-align: center;
}
.auth-hint {
color: #6c757d;
font-size: 14px;
margin: 10px 0 0 0;
}
.user-section {
background: #e8f5e8;
border: 1px solid #4caf50;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.user-section .user-info {
position: static;
display: block;
margin-bottom: 20px;
}
.user-profile {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #4caf50;
}
.user-details h3 {
margin: 0 0 5px 0;
color: #333;
font-size: 18px;
}
.user-section .user-info h3 {
margin: 0 0 10px 0;
color: #333;
}
.user-section .user-handle {
background: rgba(76, 175, 80, 0.1);
color: #2e7d32;
border: 1px solid #4caf50;
}
.user-section .user-did {
font-family: monospace;
font-size: 0.8em;
color: #666;
background: #f1f3f4;
padding: 4px 8px;
border-radius: 4px;
margin-top: 5px;
word-break: break-all;
}
.comment-form {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.comment-form h3 {
margin: 0 0 15px 0;
color: #333;
}
.comment-form textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
min-height: 100px;
}
.comment-form textarea:focus {
border-color: #1185fe;
outline: none;
box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.char-count {
color: #666;
font-size: 0.9em;
}
.post-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.post-button:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.post-button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.comments-list {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.comments-header h3 {
margin: 0;
color: #333;
}
.comments-controls {
display: flex;
gap: 10px;
}
.comments-toggle-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.comments-toggle-button:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.comment-item {
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
background: #fff;
}
.comment-item:last-child {
margin-bottom: 0;
}
.comment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #ddd;
}
.comment-author-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.comment-author {
font-weight: bold;
color: #333;
font-size: 0.95em;
}
.comment-handle {
color: #666;
font-size: 0.8em;
}
.comment-date {
color: #666;
font-size: 0.9em;
margin-left: auto;
}
.delete-button {
background: #dc3545;
color: white;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s ease;
margin-left: 8px;
}
.delete-button:hover {
background: #c82333;
transform: scale(1.05);
}
.comment-content {
line-height: 1.5;
color: #333;
margin-bottom: 10px;
}
.comment-meta {
padding: 8px;
background: #f1f3f4;
border-radius: 4px;
font-size: 0.8em;
color: #666;
}
.comment-meta small {
font-family: monospace;
}
.no-comments {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin-top: 10px;
}
/* Admin Section Styles */
.admin-section {
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.admin-section h3 {
margin: 0 0 15px 0;
color: #1976d2;
font-size: 16px;
}
.user-list-form {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.user-list-form textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.user-list-form textarea:focus {
border-color: #2196f3;
outline: none;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
}
.admin-hint {
color: #666;
font-size: 0.9em;
font-style: italic;
}
/* User List Records Styles */
.user-list-records {
margin-top: 20px;
}
.user-list-records h4 {
margin: 0 0 15px 0;
color: #1976d2;
font-size: 14px;
}
.no-user-lists {
text-align: center;
color: #666;
font-style: italic;
padding: 20px;
}
.user-list-item {
border: 1px solid #e3f2fd;
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
background: #fff;
}
.user-list-item:last-child {
margin-bottom: 0;
}
.user-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-list-date {
color: #666;
font-size: 0.9em;
font-weight: 500;
}
.user-list-content {
margin-top: 8px;
}
.user-handles {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.user-handle-tag {
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.pds-info {
color: #666;
font-size: 0.75em;
font-weight: normal;
}
.user-list-meta {
font-size: 0.8em;
color: #666;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 4px;
line-height: 1.4;
}
.user-list-meta small {
font-family: monospace;
}
/* JSON Display Styles */
.json-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
.json-button:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
.json-display {
margin-top: 12px;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.json-display h5 {
margin: 0;
padding: 8px 12px;
background: #f1f3f4;
border-bottom: 1px solid #ddd;
font-size: 0.9em;
color: #333;
}
.json-content {
margin: 0;
padding: 12px;
background: #f8f9fa;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8em;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
color: #333;
max-height: 400px;
overflow-y: auto;
}
/* Tab Navigation */
.tab-navigation {
display: flex;
border-bottom: 2px solid #e1e5e9;
margin-bottom: 20px;
}
.tab-button {
background: none;
border: none;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #656d76;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--theme-color);
background: #f6f8fa;
}
.tab-button.active {
color: var(--theme-color);
border-bottom-color: var(--theme-color);
background: #f6f8fa;
}
/* AI Chat History */
.ai-chat-list {
max-width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.chat-item {
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #ffffff;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chat-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-type-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
font-size: 12px;
font-weight: 500;
margin-left: 4px;
}
.chat-type-text {
font-size: 16px;
margin-left: 4px;
}
.chat-date {
color: #656d76;
font-size: 12px;
}
.chat-content {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
border-left: 4px solid #d1d9e0;
margin-bottom: 8px;
white-space: pre-wrap;
line-height: 1.5;
}
.chat-meta {
font-size: 11px;
color: #656d76;
}
.no-chat {
text-align: center;
padding: 40px 20px;
color: #656d76;
font-style: italic;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
// Cloudflare Access対応版の例
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Cloudflare Access Service Token
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80,
repeat_penalty: 1.1,
}
}),
});

View File

@@ -1,279 +0,0 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig } from '../config/app';
interface AIChatProps {
user: User | null;
isEnabled: boolean;
}
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [chatHistory, setChatHistory] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from environment variables
const aiConfig = {
enabled: import.meta.env.VITE_AI_ENABLED === 'true',
askAi: import.meta.env.VITE_AI_ASK_AI === 'true',
provider: import.meta.env.VITE_AI_PROVIDER || 'ollama',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b',
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) {
console.log('No AI DID configured');
return;
}
try {
// Try with agent first
const agent = atprotoOAuthService.getAgent();
if (agent) {
console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid);
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
console.log('AI profile fetched successfully:', profile.data);
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle,
displayName: profile.data.displayName,
avatar: profile.data.avatar,
description: profile.data.description
};
console.log('Setting aiProfile to:', profileData);
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
return;
}
// Fallback to public API
console.log('No agent available, trying public API for AI profile');
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
console.log('AI profile fetched via public API:', profileData);
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description
};
console.log('Setting aiProfile to:', profile);
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
return;
} else {
console.error('Public API failed with status:', response.status);
}
} catch (error) {
console.error('Failed to fetch AI profile:', error);
setAiProfile(null);
}
console.log('=== AI PROFILE FETCH FAILED ===');
};
fetchAIProfile();
}, [aiConfig.aiDid]);
useEffect(() => {
if (!isEnabled || !aiConfig.askAi) return;
// Listen for AI question posts from base.html
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
console.log('AIChat received question:', event.detail.question);
console.log('Current aiProfile state:', aiProfile);
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
} finally {
setIsProcessing(false);
}
};
// Add listener with a small delay to ensure it's ready
setTimeout(() => {
window.addEventListener('postAIQuestion', handleAIQuestion);
console.log('AIChat event listener registered');
// Notify that AI is ready
window.dispatchEvent(new CustomEvent('aiChatReady'));
}, 100);
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion);
};
}, [user, isEnabled, isProcessing, aiProfile]);
const postQuestionAndGenerateResponse = async (question: string) => {
if (!user || !aiConfig.askAi || !aiProfile) return;
setIsLoading(true);
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) throw new Error('No agent available');
// 1. Post question to ATProto
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-');
const questionRecord = {
$type: appConfig.collections.chat,
question: question,
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
context: {
page_title: document.title,
page_url: window.location.href,
},
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: appConfig.collections.chat,
rkey: rkey,
record: questionRecord,
});
console.log('Question posted to ATProto');
// 2. Get chat history
const chatRecords = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: appConfig.collections.chat,
limit: 10,
});
let chatHistoryText = '';
if (chatRecords.data.records) {
chatHistoryText = chatRecords.data.records
.map((r: any) => {
if (r.value.question) {
return `User: ${r.value.question}`;
} else if (r.value.answer) {
return `AI: ${r.value.answer}`;
}
return '';
})
.filter(Boolean)
.join('\n');
}
// 3. Generate AI response based on provider
let aiAnswer = '';
// 3. Generate AI response using Ollama via proxy
if (aiConfig.provider === 'ollama') {
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`;
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80, // Shorter responses for faster generation
repeat_penalty: 1.1,
}
}),
});
if (!response.ok) {
throw new Error('AI API request failed');
}
const data = await response.json();
aiAnswer = data.response;
}
// 4. Immediately dispatch event to update UI
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
answer: aiAnswer,
aiProfile: aiProfile,
timestamp: now.toISOString()
}
}));
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
console.log('=== SAVING AI ANSWER ===');
console.log('Current aiProfile:', aiProfile);
const answerRecord = {
$type: appConfig.collections.chat,
answer: aiAnswer,
question_rkey: rkey,
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
};
console.log('Answer record to save:', answerRecord);
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: appConfig.collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
console.error('Failed to save AI response to ATProto:', err);
});
} catch (error) {
console.error('Failed to generate AI response:', error);
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));
} finally {
setIsLoading(false);
}
};
// This component doesn't render anything - it just handles the logic
return null;
};

View File

@@ -1,79 +0,0 @@
import React, { useState, useEffect } from 'react';
import { AtprotoAgent } from '@atproto/api';
interface AIProfile {
did: string;
handle: string;
displayName?: string;
avatar?: string;
description?: string;
}
interface AIProfileProps {
aiDid: string;
}
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
const [profile, setProfile] = useState<AIProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAIProfile = async () => {
try {
// Use public API to get profile information
const agent = new AtprotoAgent({ service: 'https://bsky.social' });
const response = await agent.getProfile({ actor: aiDid });
setProfile({
did: response.data.did,
handle: response.data.handle,
displayName: response.data.displayName,
avatar: response.data.avatar,
description: response.data.description,
});
} catch (error) {
console.error('Failed to fetch AI profile:', error);
// Fallback to basic info
setProfile({
did: aiDid,
handle: 'ai-assistant',
displayName: 'AI Assistant',
description: 'AI assistant for this blog',
});
} finally {
setLoading(false);
}
};
if (aiDid) {
fetchAIProfile();
}
}, [aiDid]);
if (loading) {
return <div className="ai-profile-loading">Loading AI profile...</div>;
}
if (!profile) {
return null;
}
return (
<div className="ai-profile">
<div className="ai-avatar">
{profile.avatar ? (
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
) : (
<div className="ai-avatar-placeholder">🤖</div>
)}
</div>
<div className="ai-info">
<div className="ai-name">{profile.displayName || profile.handle}</div>
<div className="ai-handle">@{profile.handle}</div>
{profile.description && (
<div className="ai-description">{profile.description}</div>
)}
</div>
</div>
);
};

View File

@@ -1,120 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Card as CardType, CardRarity } from '../types/card';
import '../styles/Card.css';
interface CardProps {
card: CardType;
isRevealing?: boolean;
detailed?: boolean;
}
const CARD_INFO: Record<number, { name: string; color: string }> = {
0: { name: "アイ", color: "#fff700" },
1: { name: "夢幻", color: "#b19cd9" },
2: { name: "光彩", color: "#ffd700" },
3: { name: "中性子", color: "#cacfd2" },
4: { name: "太陽", color: "#ff6b35" },
5: { name: "夜空", color: "#1a1a2e" },
6: { name: "雪", color: "#e3f2fd" },
7: { name: "雷", color: "#ffd93d" },
8: { name: "超究", color: "#6c5ce7" },
9: { name: "剣", color: "#a8e6cf" },
10: { name: "破壊", color: "#ff4757" },
11: { name: "地球", color: "#4834d4" },
12: { name: "天の川", color: "#9c88ff" },
13: { name: "創造", color: "#00d2d3" },
14: { name: "超新星", color: "#ff9ff3" },
15: { name: "世界", color: "#54a0ff" },
};
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
const getRarityClass = () => {
switch (card.status) {
case CardRarity.UNIQUE:
return 'card-unique';
case CardRarity.KIRA:
return 'card-kira';
case CardRarity.SUPER_RARE:
return 'card-super-rare';
case CardRarity.RARE:
return 'card-rare';
default:
return 'card-normal';
}
};
if (!detailed) {
// Simple view - only image and frame
return (
<motion.div
className={`card card-simple ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="card-frame">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image-simple"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</motion.div>
);
}
// Detailed view - all information
return (
<motion.div
className={`card ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
style={{
'--card-color': cardInfo.color,
} as React.CSSProperties}
>
<div className="card-inner">
<div className="card-header">
<span className="card-id">#{card.id}</span>
<span className="card-cp">CP: {card.cp}</span>
</div>
<div className="card-image-container">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="card-content">
<h3 className="card-name">{cardInfo.name}</h3>
{card.is_unique && (
<div className="unique-badge">UNIQUE</div>
)}
</div>
{card.skill && (
<div className="card-skill">
<p>{card.skill}</p>
</div>
)}
<div className="card-footer">
<span className="card-rarity">{card.status.toUpperCase()}</span>
</div>
</div>
</motion.div>
);
};

View File

@@ -1,171 +0,0 @@
import React, { useState, useEffect } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { Card } from './Card';
import '../styles/CardBox.css';
interface CardBoxProps {
userDid: string;
}
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
const [boxData, setBoxData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showJson, setShowJson] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadBoxData();
}, [userDid]);
const loadBoxData = async () => {
setLoading(true);
setError(null);
try {
const data = await atprotoOAuthService.getCardsFromBox();
setBoxData(data);
} catch (err) {
console.error('カードボックス読み込みエラー:', err);
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
} finally {
setLoading(false);
}
};
const handleSaveToBox = async () => {
// 現在のカードデータを取得してボックスに保存
// この部分は親コンポーネントから渡すか、APIから取得する必要があります
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
};
const handleDeleteBox = async () => {
if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
return;
}
setIsDeleting(true);
setError(null);
try {
await atprotoOAuthService.deleteCardBox();
setBoxData({ records: [] });
alert('カードボックスを削除しました');
} catch (err) {
console.error('カードボックス削除エラー:', err);
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
} finally {
setIsDeleting(false);
}
};
if (loading) {
return (
<div className="card-box-container">
<div className="loading">...</div>
</div>
);
}
if (error) {
return (
<div className="card-box-container">
<div className="error">: {error}</div>
<button onClick={loadBoxData} className="retry-button">
</button>
</div>
);
}
const records = boxData?.records || [];
const selfRecord = records.find((record: any) => record.uri.includes('/self'));
const cards = selfRecord?.value?.cards || [];
return (
<div className="card-box-container">
<div className="card-box-header">
<h3>📦 atproto </h3>
<div className="box-actions">
<button
onClick={() => setShowJson(!showJson)}
className="json-button"
>
{showJson ? 'JSON非表示' : 'JSON表示'}
</button>
<button onClick={loadBoxData} className="refresh-button">
🔄
</button>
{cards.length > 0 && (
<button
onClick={handleDeleteBox}
className="delete-button"
disabled={isDeleting}
>
{isDeleting ? '削除中...' : '🗑️ 削除'}
</button>
)}
</div>
</div>
<div className="uri-display">
<p>
<strong>📍 URI:</strong>
<code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
</p>
</div>
{showJson && (
<div className="json-display">
<h4>Raw JSON :</h4>
<pre className="json-content">
{JSON.stringify(boxData, null, 2)}
</pre>
</div>
)}
<div className="box-stats">
<p>
<strong>:</strong> {cards.length}
{selfRecord?.value?.updated_at && (
<>
<br />
<strong>:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
</>
)}
</p>
</div>
{cards.length > 0 ? (
<>
<div className="card-grid">
{cards.map((card: any, index: number) => (
<div key={index} className="box-card-item">
<Card
card={{
id: card.id,
cp: card.cp,
status: card.status,
skill: card.skill,
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id
}}
/>
<div className="card-info">
<small>ID: {card.id} | CP: {card.cp}</small>
</div>
</div>
))}
</div>
</>
) : (
<div className="empty-box">
<p></p>
<p></p>
</div>
)}
</div>
);
};

View File

@@ -1,113 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card } from './Card';
import { cardApi } from '../services/api';
import { Card as CardType } from '../types/card';
import '../styles/CardList.css';
interface CardMasterData {
id: number;
name: string;
ja_name: string;
description: string;
base_cp_min: number;
base_cp_max: number;
}
export const CardList: React.FC = () => {
const [loading, setLoading] = useState(true);
const [masterData, setMasterData] = useState<CardMasterData[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadMasterData();
}, []);
const loadMasterData = async () => {
try {
setLoading(true);
const response = await fetch('http://localhost:8000/api/v1/cards/master');
if (!response.ok) {
throw new Error('Failed to fetch card master data');
}
const data = await response.json();
setMasterData(data);
} catch (err) {
console.error('Error loading card master data:', err);
setError(err instanceof Error ? err.message : 'Failed to load card data');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="card-list-container">
<div className="loading">Loading card data...</div>
</div>
);
}
if (error) {
return (
<div className="card-list-container">
<div className="error">Error: {error}</div>
<button onClick={loadMasterData}>Retry</button>
</div>
);
}
// Create cards for all rarity patterns
const rarityPatterns = ['normal', 'unique'] as const;
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
masterData.forEach(data => {
rarityPatterns.forEach(pattern => {
const card: CardType = {
id: data.id,
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
status: pattern,
skill: null,
owner_did: 'sample',
obtained_at: new Date().toISOString(),
is_unique: pattern === 'unique',
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
};
displayCards.push({
card,
data,
patternName: `${data.id}-${pattern}`
});
});
});
return (
<div className="card-list-container">
<header className="card-list-header">
<h1>ai.card </h1>
<p></p>
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
</header>
<div className="card-list-simple-grid">
{displayCards.map(({ card, data, patternName }) => (
<div key={patternName} className="card-list-simple-item">
<Card card={card} detailed={false} />
<div className="card-info-details">
<p><strong>ID:</strong> {data.id}</p>
<p><strong>Name:</strong> {data.name}</p>
<p><strong>:</strong> {data.ja_name}</p>
<p><strong>:</strong> {card.status}</p>
<p><strong>CP:</strong> {card.cp}</p>
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
{data.description && (
<p className="card-description">{data.description}</p>
)}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -1,133 +0,0 @@
import React, { useState, useEffect } from 'react';
import { aiCardApi } from '../services/api';
import '../styles/CollectionAnalysis.css';
interface AnalysisData {
total_cards: number;
unique_cards: number;
rarity_distribution: Record<string, number>;
collection_score: number;
recommendations: string[];
}
interface CollectionAnalysisProps {
userDid: string;
}
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = async () => {
if (!userDid) return;
setLoading(true);
setError(null);
try {
const result = await aiCardApi.analyzeCollection(userDid);
setAnalysis(result);
} catch (err) {
console.error('Collection analysis failed:', err);
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAnalysis();
}, [userDid]);
if (loading) {
return (
<div className="collection-analysis">
<div className="analysis-loading">
<div className="loading-spinner"></div>
<p>AI分析中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="collection-analysis">
<div className="analysis-error">
<p>{error}</p>
<button onClick={loadAnalysis} className="retry-button">
</button>
</div>
</div>
);
}
if (!analysis) {
return (
<div className="collection-analysis">
<div className="analysis-empty">
<p></p>
<button onClick={loadAnalysis} className="analyze-button">
</button>
</div>
</div>
);
}
return (
<div className="collection-analysis">
<h3>🧠 AI </h3>
<div className="analysis-stats">
<div className="stat-card">
<div className="stat-value">{analysis.total_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.unique_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.collection_score}</div>
<div className="stat-label"></div>
</div>
</div>
<div className="rarity-distribution">
<h4></h4>
<div className="rarity-bars">
{Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
<div key={rarity} className="rarity-bar">
<span className="rarity-name">{rarity}</span>
<div className="bar-container">
<div
className={`bar bar-${rarity.toLowerCase()}`}
style={{ width: `${(count / analysis.total_cards) * 100}%` }}
></div>
</div>
<span className="rarity-count">{count}</span>
</div>
))}
</div>
</div>
{analysis.recommendations && analysis.recommendations.length > 0 && (
<div className="recommendations">
<h4>🎯 AI推奨</h4>
<ul>
{analysis.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
<button onClick={loadAnalysis} className="refresh-analysis">
</button>
</div>
);
};

View File

@@ -1,130 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from './Card';
import { Card as CardType } from '../types/card';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/GachaAnimation.css';
interface GachaAnimationProps {
card: CardType;
animationType: string;
onComplete: () => void;
}
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
card,
animationType,
onComplete
}) => {
const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
const [showCard, setShowCard] = useState(false);
const [isSharing, setIsSharing] = useState(false);
useEffect(() => {
const timer1 = setTimeout(() => setPhase('revealing'), 1500);
const timer2 = setTimeout(() => {
setPhase('complete');
setShowCard(true);
}, 3000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [onComplete]);
const handleCardClick = () => {
if (showCard) {
onComplete();
}
};
const handleSaveToCollection = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isSharing) return;
setIsSharing(true);
try {
await atprotoOAuthService.saveCardToCollection(card);
alert('カードデータをatprotoコレクションに保存しました');
} catch (error) {
console.error('保存エラー:', error);
alert('保存に失敗しました。認証が必要かもしれません。');
} finally {
setIsSharing(false);
}
};
const getEffectClass = () => {
switch (animationType) {
case 'unique':
return 'effect-unique';
case 'kira':
return 'effect-kira';
case 'rare':
return 'effect-rare';
default:
return 'effect-normal';
}
};
return (
<div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
<AnimatePresence mode="wait">
{phase === 'opening' && (
<motion.div
key="opening"
className="gacha-opening"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="gacha-pack">
<div className="pack-glow" />
</div>
</motion.div>
)}
{phase === 'revealing' && (
<motion.div
key="revealing"
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<Card card={card} isRevealing={true} />
</motion.div>
)}
{phase === 'complete' && showCard && (
<motion.div
key="complete"
initial={{ scale: 1, rotateY: 0 }}
animate={{ scale: 1, rotateY: 0 }}
className="card-final"
>
<Card card={card} isRevealing={false} />
<div className="card-actions">
<button
className="save-button"
onClick={handleSaveToCollection}
disabled={isSharing}
>
{isSharing ? '保存中...' : '💾 atprotoに保存'}
</button>
<div className="click-hint"></div>
</div>
</motion.div>
)}
</AnimatePresence>
{animationType === 'unique' && (
<div className="unique-effect">
<div className="unique-particles" />
<div className="unique-burst" />
</div>
)}
</div>
);
};

View File

@@ -1,144 +0,0 @@
import React, { useState, useEffect } from 'react';
import { cardApi, aiCardApi } from '../services/api';
import '../styles/GachaStats.css';
interface GachaStatsData {
total_draws: number;
cards_by_rarity: Record<string, number>;
success_rates: Record<string, number>;
recent_activity: Array<{
timestamp: string;
user_did: string;
card_name: string;
rarity: string;
}>;
}
export const GachaStats: React.FC = () => {
const [stats, setStats] = useState<GachaStatsData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [useAI, setUseAI] = useState(true);
const loadStats = async () => {
setLoading(true);
setError(null);
try {
let result;
if (useAI) {
try {
result = await aiCardApi.getEnhancedStats();
} catch (aiError) {
console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
setUseAI(false);
result = await cardApi.getGachaStats();
}
} else {
result = await cardApi.getGachaStats();
}
setStats(result);
} catch (err) {
console.error('Gacha stats failed:', err);
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStats();
}, []);
if (loading) {
return (
<div className="gacha-stats">
<div className="stats-loading">
<div className="loading-spinner"></div>
<p>...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="gacha-stats">
<div className="stats-error">
<p>{error}</p>
<button onClick={loadStats} className="retry-button">
</button>
</div>
</div>
);
}
if (!stats) {
return (
<div className="gacha-stats">
<div className="stats-empty">
<p></p>
<button onClick={loadStats} className="load-stats-button">
</button>
</div>
</div>
);
}
return (
<div className="gacha-stats">
<h3>📊 </h3>
<div className="stats-overview">
<div className="overview-card">
<div className="overview-value">{stats.total_draws}</div>
<div className="overview-label"></div>
</div>
</div>
<div className="rarity-stats">
<h4></h4>
<div className="rarity-grid">
{Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
<div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
<div className="rarity-count">{count}</div>
<div className="rarity-name">{rarity}</div>
{stats.success_rates[rarity] && (
<div className="success-rate">
{(stats.success_rates[rarity] * 100).toFixed(1)}%
</div>
)}
</div>
))}
</div>
</div>
{stats.recent_activity && stats.recent_activity.length > 0 && (
<div className="recent-activity">
<h4></h4>
<div className="activity-list">
{stats.recent_activity.slice(0, 5).map((activity, index) => (
<div key={index} className="activity-item">
<div className="activity-time">
{new Date(activity.timestamp).toLocaleString()}
</div>
<div className="activity-details">
<span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
{activity.rarity}
</span>
<span className="card-name">{activity.card_name}</span>
</div>
</div>
))}
</div>
</div>
)}
<button onClick={loadStats} className="refresh-stats">
</button>
</div>
);
};

View File

@@ -1,203 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { authService } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/Login.css';
interface LoginProps {
onLogin: (did: string, handle: string) => void;
onClose: () => void;
defaultHandle?: string;
}
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
const [identifier, setIdentifier] = useState(defaultHandle || '');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOAuthLogin = async () => {
setError(null);
setIsLoading(true);
try {
// Prompt for handle if not provided
const handle = identifier.trim() || undefined;
await atprotoOAuthService.initiateOAuthFlow(handle);
// OAuth flow will redirect, so we don't need to handle the response here
} catch (err) {
setError('OAuth認証の開始に失敗しました。');
setIsLoading(false);
}
};
const handleLegacyLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const response = await authService.login(identifier, password);
onLogin(response.did, response.handle);
} catch (err) {
setError('ログインに失敗しました。認証情報を確認してください。');
} finally {
setIsLoading(false);
}
};
return (
<motion.div
className="login-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="login-modal"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.5 }}
onClick={(e) => e.stopPropagation()}
>
<h2>atprotoログイン</h2>
<div className="login-mode-selector">
<button
type="button"
className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
onClick={() => setLoginMode('oauth')}
>
OAuth 2.1 ()
</button>
<button
type="button"
className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
onClick={() => setLoginMode('legacy')}
>
</button>
</div>
{loginMode === 'oauth' ? (
<div className="oauth-login">
<div className="oauth-info">
<h3>🔐 OAuth 2.1 </h3>
<p>
atproto認証サーバーにリダイレクトされます
</p>
{(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
<div className="dev-notice">
<small>🛠 開発環境: モック認証を使用しますBlueskyにはアクセスしません</small>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="oauth-identifier">Bluesky Handle</label>
<input
id="oauth-identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle.bsky.social"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="button"
className="oauth-login-button"
onClick={handleOAuthLogin}
disabled={isLoading || !identifier.trim()}
>
{isLoading ? '認証開始中...' : 'atprotoで認証'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</div>
) : (
<form onSubmit={handleLegacyLogin}>
<div className="form-group">
<label htmlFor="identifier"> DID</label>
<input
id="identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle または did:plc:..."
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password"></label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="アプリパスワード"
required
disabled={isLoading}
/>
<small>
<a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer">
</a>
使
</small>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</form>
)}
<div className="login-info">
<p>
ai.logはatprotoアカウントを使用します
PDSに保存されます
</p>
</div>
</motion.div>
</motion.div>
);
};

View File

@@ -1,253 +0,0 @@
import React, { useEffect, useState } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
interface OAuthCallbackProps {
onSuccess: (did: string, handle: string) => void;
onError: (error: string) => void;
}
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
console.log('Current URL:', window.location.href);
const [isProcessing, setIsProcessing] = useState(true);
const [needsHandle, setNeedsHandle] = useState(false);
const [handle, setHandle] = useState('');
const [tempSession, setTempSession] = useState<any>(null);
useEffect(() => {
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
console.error('OAuth callback timeout');
onError('OAuth認証がタイムアウトしました');
}, 10000); // 10 second timeout
const handleCallback = async () => {
console.log('=== HANDLE CALLBACK STARTED ===');
try {
// Handle both query params (?) and hash params (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const queryParams = new URLSearchParams(window.location.search);
// Try hash first (Bluesky uses this), then fallback to query
const code = hashParams.get('code') || queryParams.get('code');
const state = hashParams.get('state') || queryParams.get('state');
const error = hashParams.get('error') || queryParams.get('error');
const iss = hashParams.get('iss') || queryParams.get('iss');
console.log('OAuth callback parameters:', {
code: code ? code.substring(0, 20) + '...' : null,
state: state,
error: error,
iss: iss,
hash: window.location.hash,
search: window.location.search
});
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
// Use the official BrowserOAuthClient to handle the callback
const result = await atprotoOAuthService.handleOAuthCallback();
if (result) {
console.log('OAuth callback completed successfully:', result);
// Success - notify parent component
onSuccess(result.did, result.handle);
} else {
throw new Error('OAuth callback did not return a session');
}
} catch (error) {
console.error('OAuth callback error:', error);
// Even if OAuth fails, try to continue with a fallback approach
console.warn('OAuth callback failed, attempting fallback...');
try {
// Create a minimal session to allow the user to proceed
const fallbackSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai'
};
// Notify success with fallback session
onSuccess(fallbackSession.did, fallbackSession.handle);
} catch (fallbackError) {
console.error('Fallback also failed:', fallbackError);
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
}
} finally {
clearTimeout(timeoutId); // Clear timeout on completion
setIsProcessing(false);
}
};
handleCallback();
// Cleanup function
return () => {
clearTimeout(timeoutId);
};
}, [onSuccess, onError]);
const handleSubmitHandle = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
const trimmedHandle = handle.trim();
if (!trimmedHandle) {
console.log('Handle is empty');
return;
}
console.log('Submitting handle:', trimmedHandle);
setIsProcessing(true);
try {
// Resolve DID from handle
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
console.log('Resolved DID:', did);
// Update session with resolved DID and handle
const updatedSession = {
...tempSession,
did: did,
handle: trimmedHandle
};
// Save updated session
atprotoOAuthService.saveSessionToStorage(updatedSession);
// Success - notify parent component
onSuccess(did, trimmedHandle);
} catch (error) {
console.error('Failed to resolve DID:', error);
setIsProcessing(false);
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
}
};
if (needsHandle) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<h2>Blueskyハンドルを入力してください</h2>
<p>OAuth認証は成功しました</p>
<p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
: {handle || '(未入力)'} | : {handle.length}
</p>
<form onSubmit={handleSubmitHandle}>
<input
type="text"
value={handle}
onChange={(e) => {
console.log('Input changed:', e.target.value);
setHandle(e.target.value);
}}
placeholder="例: syui.ai または user.bsky.social"
autoFocus
style={{
width: '100%',
padding: '10px',
marginTop: '20px',
marginBottom: '20px',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '16px',
backgroundColor: '#1a1a1a',
color: 'white'
}}
/>
<button
type="submit"
disabled={!handle.trim() || isProcessing}
style={{
padding: '12px 24px',
backgroundColor: handle.trim() ? '#667eea' : '#444',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: handle.trim() ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: 'bold',
transition: 'all 0.3s ease',
width: '100%'
}}
>
{isProcessing ? '処理中...' : '続行'}
</button>
</form>
</div>
</div>
);
}
if (isProcessing) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<div className="loading-spinner"></div>
</div>
</div>
);
}
return null;
};
// CSS styles (inline for simplicity)
const styles = `
.oauth-callback {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
color: #333;
z-index: 9999;
}
.oauth-processing {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top: 3px solid #1185fe;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
// Inject styles
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);

View File

@@ -1,42 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OAuthCallback } from './OAuthCallback';
export const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
console.log('Current URL:', window.location.href);
console.log('Search params:', window.location.search);
console.log('Pathname:', window.location.pathname);
}, []);
const handleSuccess = (did: string, handle: string) => {
console.log('OAuth success, redirecting to home:', { did, handle });
// Add a small delay to ensure state is properly updated
setTimeout(() => {
navigate('/', { replace: true });
}, 100);
};
const handleError = (error: string) => {
console.error('OAuth error, redirecting to home:', error);
// Add a small delay before redirect
setTimeout(() => {
navigate('/', { replace: true });
}, 2000); // Give user time to see error
};
return (
<div>
<h2>Processing OAuth callback...</h2>
<OAuthCallback
onSuccess={handleSuccess}
onError={handleError}
/>
</div>
);
};

View File

@@ -1,110 +0,0 @@
// Application configuration
export interface AppConfig {
adminDid: string;
collections: {
comment: string;
user: string;
chat: string;
};
host: string;
rkey?: string; // Current post rkey if on post page
aiEnabled: boolean;
aiAskAi: boolean;
aiProvider: string;
aiModel: string;
aiHost: string;
bskyPublicApi: string;
}
// Generate collection names from host
// Format: ${reg}.${name}.${sub}
// Example: log.syui.ai -> ai.syui.log
function generateCollectionNames(host: string): { comment: string; user: string; chat: string } {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
// Split host into parts
const parts = cleanHost.split('.');
if (parts.length < 2) {
throw new Error('Invalid host format');
}
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const collectionBase = reversedParts.join('.');
return {
comment: collectionBase,
user: `${collectionBase}.user`,
chat: `${collectionBase}.chat`
};
} catch (error) {
console.warn('Failed to generate collection names from host:', host, error);
// Fallback to default collections
return {
comment: 'ai.syui.log',
user: 'ai.syui.log.user',
chat: 'ai.syui.log.chat'
};
}
}
// Extract rkey from current URL
// /posts/xxx.html -> xxx
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\.html$/);
return match ? match[1] : undefined;
}
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
// Priority: Environment variables > Auto-generated from host
const autoGeneratedCollections = generateCollectionNames(host);
const collections = {
comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment,
user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user,
chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat,
};
const rkey = extractRkeyFromUrl();
// AI configuration
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
console.log('App configuration:', {
host,
adminDid,
collections,
rkey: rkey || 'none (not on post page)',
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost },
bskyPublicApi
});
return {
adminDid,
collections,
host,
rkey,
aiEnabled,
aiAskAi,
aiProvider,
aiModel,
aiHost,
bskyPublicApi
};
}
// Export singleton instance
export const appConfig = getAppConfig();

View File

@@ -1,30 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
import { CardList } from './components/CardList'
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
// DISABLED: This may interfere with BrowserOAuthClient
// OAuthEndpointHandler.init()
// Mount React app to all comment-atproto divs
const mountPoints = document.querySelectorAll('#comment-atproto');
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
mountPoints.forEach((mountPoint, index) => {
console.log(`Mounting React app to comment-atproto #${index + 1}`);
ReactDOM.createRoot(mountPoint as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/list" element={<CardList />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
);
});

View File

@@ -1,107 +0,0 @@
import axios from 'axios';
import { CardDrawResult } from '../types/card';
// ai.card 直接APIアクセスメイン
const API_HOST = import.meta.env.VITE_API_HOST || '';
const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
// ai.gpt MCP統合オプション機能
const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'
? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
: null;
const cardApi_internal = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
const aiGptApi = AI_GPT_BASE ? axios.create({
baseURL: AI_GPT_BASE,
headers: {
'Content-Type': 'application/json',
},
}) : null;
// ai.cardの直接API基本機能
export const cardApi = {
drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => {
const response = await cardApi_internal.post('/cards/draw', {
user_did: userDid,
is_paid: isPaid,
});
return response.data;
},
getUserCards: async (userDid: string) => {
const response = await cardApi_internal.get(`/cards/user/${userDid}`);
return response.data;
},
getCardDetails: async (cardId: number) => {
const response = await cardApi_internal.get(`/cards/${cardId}`);
return response.data;
},
getUniqueCards: async () => {
const response = await cardApi_internal.get('/cards/unique');
return response.data;
},
getGachaStats: async () => {
const response = await cardApi_internal.get('/cards/stats');
return response.data;
},
// システム状態確認
getSystemStatus: async () => {
const response = await cardApi_internal.get('/health');
return response.data;
},
};
// ai.gpt統合APIオプション機能 - AI拡張
export const aiCardApi = {
analyzeCollection: async (userDid: string) => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_analyze_collection', {
params: { did: userDid }
});
return response.data.data;
} catch (error) {
console.warn('ai.gpt AI分析機能が利用できません:', error);
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
}
},
getEnhancedStats: async () => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_get_gacha_stats');
return response.data.data;
} catch (error) {
console.warn('ai.gpt AI統計機能が利用できません:', error);
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
}
},
// AI機能が利用可能かチェック
isAIAvailable: async (): Promise<boolean> => {
if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
return false;
}
try {
await aiGptApi.get('/health');
return true;
} catch (error) {
return false;
}
},
};

View File

@@ -1,686 +0,0 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
import { Agent } from '@atproto/api';
interface AtprotoSession {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
email?: string;
emailConfirmed?: boolean;
}
class AtprotoOAuthService {
private oauthClient: BrowserOAuthClient | null = null;
private agent: Agent | null = null;
private initializePromise: Promise<void> | null = null;
constructor() {
// Don't initialize immediately, wait for first use
}
private async initialize(): Promise<void> {
// Prevent multiple initializations
if (this.initializePromise) {
return this.initializePromise;
}
this.initializePromise = this._doInitialize();
return this.initializePromise;
}
private async _doInitialize(): Promise<void> {
try {
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
// Generate client ID based on current origin
const clientId = this.getClientId();
console.log('Client ID:', clientId);
// Support multiple PDS hosts for OAuth
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
});
console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
// Try to restore existing session
const result = await this.oauthClient.init();
if (result?.session) {
console.log('Existing session restored:', {
did: result.session.did,
handle: result.session.handle || 'unknown',
hasAccessJwt: !!result.session.accessJwt,
hasRefreshJwt: !!result.session.refreshJwt
});
// Create Agent instance with proper configuration
console.log('Creating Agent with session:', result.session);
// Delete the old agent initialization code - we'll create it properly below
// Set the session after creating the agent
// The session object from BrowserOAuthClient appears to be a special object
console.log('Full session object:', result.session);
console.log('Session type:', typeof result.session);
console.log('Session constructor:', result.session?.constructor?.name);
// Try to iterate over the session object
if (result.session) {
console.log('Session properties:');
for (const key in result.session) {
console.log(` ${key}:`, result.session[key]);
}
// Check if session has methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
console.log('Session methods:', methods);
}
// BrowserOAuthClient might return a Session object that needs to be used with the agent
// Let's try to use the session object directly with the agent
if (result.session) {
// Process the session to extract DID and handle
const sessionData = await this.processSession(result.session);
console.log('Session processed during initialization:', sessionData);
}
} else {
console.log('No existing session found');
}
} catch (error) {
console.error('Failed to initialize OAuth client:', error);
this.initializePromise = null; // Reset on error to allow retry
throw error;
}
}
private async processSession(session: any): Promise<{ did: string; handle: string }> {
console.log('Processing session:', session);
// Log full session structure
console.log('Session structure:');
console.log('- sub:', session.sub);
console.log('- did:', session.did);
console.log('- handle:', session.handle);
console.log('- iss:', session.iss);
console.log('- aud:', session.aud);
// Check if agent has properties we can access
if (session.agent) {
console.log('- agent:', session.agent);
console.log('- agent.did:', session.agent?.did);
console.log('- agent.handle:', session.agent?.handle);
}
const did = session.sub || session.did;
let handle = session.handle || 'unknown';
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
console.log('Agent created directly with session');
// Check if agent has session info after creation
console.log('Agent after creation:');
console.log('- agent.did:', this.agent.did);
console.log('- agent.session:', this.agent.session);
if (this.agent.session) {
console.log('- agent.session.did:', this.agent.session.did);
console.log('- agent.session.handle:', this.agent.session.handle);
}
} catch (err) {
console.log('Failed to create Agent with session directly, trying dpopFetch method');
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
fetch: session.dpopFetch
});
}
// Store basic session info
(this as any)._sessionInfo = { did, handle };
// If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') {
console.log('Handle not in session, attempting to resolve...');
// Method 1: Try using the agent to get profile
try {
await new Promise(resolve => setTimeout(resolve, 300));
const profile = await this.agent.getProfile({ actor: did });
if (profile.data.handle) {
handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Successfully resolved handle via getProfile:', handle);
return { did, handle };
}
} catch (err) {
console.error('getProfile failed:', err);
}
// Method 2: Try using describeRepo
try {
const repoDesc = await this.agent.com.atproto.repo.describeRepo({
repo: did
});
if (repoDesc.data.handle) {
handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Got handle from describeRepo:', handle);
return { did, handle };
}
} catch (err) {
console.error('describeRepo failed:', err);
}
// Method 3: Hardcoded fallback for known DIDs
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
handle = 'syui.ai';
(this as any)._sessionInfo.handle = handle;
console.log('Using hardcoded handle for known DID');
}
}
return { did, handle };
}
private getClientId(): string {
// Use environment variable if available
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
if (envClientId) {
console.log('Using client ID from environment:', envClientId);
return envClientId;
}
const origin = window.location.origin;
// For localhost development, use undefined for loopback client
// The BrowserOAuthClient will handle this automatically
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
console.log('Using loopback client for localhost development');
return undefined as any; // Loopback client
}
// Default: use origin-based client metadata
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
console.log('Detecting PDS for handle:', handle);
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social',
};
// Check if handle ends with known PDS domains
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) {
console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`);
return pdsUrl;
}
}
// Default to bsky.social
console.log(`Handle ${handle} using default PDS: https://bsky.social`);
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
console.log('=== INITIATING OAUTH FLOW ===');
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
}
// If handle is not provided, prompt user
if (!handle) {
handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):');
if (!handle) {
throw new Error('Handle is required for authentication');
}
}
console.log('Starting OAuth flow for handle:', handle);
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
console.log('Detected PDS for handle:', { handle, pdsUrl });
// Re-initialize OAuth client with correct PDS if needed
if (pdsUrl !== 'https://bsky.social') {
console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
});
}
// Start OAuth authorization flow
console.log('Calling oauthClient.authorize with handle:', handle);
try {
const authUrl = await this.oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
console.log('Authorization URL generated:', authUrl.toString());
console.log('URL breakdown:', {
protocol: authUrl.protocol,
hostname: authUrl.hostname,
pathname: authUrl.pathname,
search: authUrl.search
});
// Store some debug info before redirect
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
timestamp: new Date().toISOString(),
handle: handle,
authUrl: authUrl.toString(),
currentUrl: window.location.href
}));
// Redirect to authorization server
console.log('About to redirect to:', authUrl.toString());
window.location.href = authUrl.toString();
} catch (authorizeError) {
console.error('oauthClient.authorize failed:', authorizeError);
console.error('Error details:', {
name: authorizeError.name,
message: authorizeError.message,
stack: authorizeError.stack
});
throw authorizeError;
}
} catch (error) {
console.error('Failed to initiate OAuth flow:', error);
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== HANDLING OAUTH CALLBACK ===');
console.log('Current URL:', window.location.href);
console.log('URL hash:', window.location.hash);
console.log('URL search:', window.location.search);
// BrowserOAuthClient should automatically handle the callback
// We just need to initialize it and it will process the current URL
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
}
console.log('OAuth client ready, initializing to process callback...');
// Call init() again to process the callback URL
const result = await this.oauthClient.init();
console.log('OAuth callback processing result:', result);
if (result?.session) {
// Process the session
return this.processSession(result.session);
}
// If no session yet, wait a bit and try again
console.log('No session found immediately, waiting...');
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to check session again
const sessionCheck = await this.checkSession();
if (sessionCheck) {
console.log('Session found after delay:', sessionCheck);
return sessionCheck;
}
console.warn('OAuth callback completed but no session was created');
return null;
} catch (error) {
console.error('OAuth callback handling failed:', error);
console.error('Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
}
}
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== CHECK SESSION CALLED ===');
if (!this.oauthClient) {
console.log('No OAuth client, initializing...');
await this.initialize();
}
if (!this.oauthClient) {
console.log('OAuth client initialization failed');
return null;
}
console.log('Running oauthClient.init() to check session...');
const result = await this.oauthClient.init();
console.log('oauthClient.init() result:', result);
if (result?.session) {
// Use the common session processing method
return this.processSession(result.session);
}
return null;
} catch (error) {
console.error('Session check failed:', error);
return null;
}
}
getAgent(): Agent | null {
return this.agent;
}
getSession(): AtprotoSession | null {
console.log('getSession called');
console.log('Current state:', {
hasAgent: !!this.agent,
hasAgentSession: !!this.agent?.session,
hasOAuthClient: !!this.oauthClient,
hasSessionInfo: !!(this as any)._sessionInfo
});
// First check if we have an agent with session
if (this.agent?.session) {
const session = {
did: this.agent.session.did,
handle: this.agent.session.handle || 'unknown',
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
console.log('Returning agent session:', session);
return session;
}
// If no agent.session but we have stored session info, return that
if ((this as any)._sessionInfo) {
const session = {
did: (this as any)._sessionInfo.did,
handle: (this as any)._sessionInfo.handle,
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected',
};
console.log('Returning stored session info:', session);
return session;
}
console.log('No session available');
return null;
}
isAuthenticated(): boolean {
return !!this.agent || !!(this as any)._sessionInfo;
}
getUser(): { did: string; handle: string } | null {
const session = this.getSession();
if (!session) return null;
return {
did: session.did,
handle: session.handle
};
}
async logout(): Promise<void> {
try {
console.log('=== LOGGING OUT ===');
// Clear Agent
this.agent = null;
console.log('Agent cleared');
// Clear BrowserOAuthClient session
if (this.oauthClient) {
console.log('Clearing OAuth client session...');
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
console.log('OAuth client signed out');
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
console.log('OAuth client revoked');
} else {
console.log('No explicit signOut method found on OAuth client');
}
} catch (oauthError) {
console.error('OAuth client logout error:', oauthError);
}
// Reset the OAuth client to force re-initialization
this.oauthClient = null;
this.initializePromise = null;
}
// Clear any stored session data
localStorage.removeItem('atproto_session');
sessionStorage.clear();
// Clear all localStorage items that might be related to OAuth
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key);
});
console.log('=== LOGOUT COMPLETED ===');
// Force page reload to ensure clean state
setTimeout(() => {
window.location.reload();
}, 100);
} catch (error) {
console.error('Logout failed:', error);
}
}
// カードデータをatproto collectionに保存
async saveCardToBox(userCards: any[]): Promise<void> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
console.log('Saving cards to atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const collection = 'ai.card.box';
const rkey = 'self';
const createdAt = new Date().toISOString();
// カードボックスのレコード
const record = {
$type: 'ai.card.box',
cards: userCards.map(card => ({
id: card.id,
cp: card.cp,
status: card.status,
skill: card.skill,
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id
})),
total_cards: userCards.length,
updated_at: createdAt,
createdAt: createdAt
};
console.log('PutRecord request:', {
repo: did,
collection: collection,
rkey: rkey,
record: record
});
// Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({
repo: did,
collection: collection,
rkey: rkey,
record: record
});
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) {
console.error('カードボックス保存エラー:', error);
throw error;
}
}
// ai.card.boxからカード一覧を取得
async getCardsFromBox(): Promise<any> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
console.log('Fetching cards from atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const response = await this.agent.com.atproto.repo.getRecord({
repo: did,
collection: 'ai.card.box',
rkey: 'self'
});
console.log('Cards from box response:', response);
// Convert to expected format
const result = {
records: [{
uri: `at://${did}/ai.card.box/self`,
cid: response.data.cid,
value: response.data.value
}]
};
return result;
} catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
return { records: [] };
}
throw error;
}
}
// ai.card.boxのコレクションを削除
async deleteCardBox(): Promise<void> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
console.log('Deleting card box collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const response = await this.agent.com.atproto.repo.deleteRecord({
repo: did,
collection: 'ai.card.box',
rkey: 'self'
});
console.log('Card box deleted successfully:', response);
} catch (error) {
console.error('カードボックス削除エラー:', error);
throw error;
}
}
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
console.warn('Please use the proper OAuth flow instead');
// For backward compatibility, store in localStorage
const session: AtprotoSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai',
accessJwt: accessJwt,
refreshJwt: refreshJwt
};
localStorage.setItem('atproto_session', JSON.stringify(session));
console.log('Manual tokens stored in localStorage for backward compatibility');
}
// 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void {
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
localStorage.setItem('atproto_session', JSON.stringify(session));
}
async backupUserCards(userCards: any[]): Promise<void> {
return this.saveCardToBox(userCards);
}
}
export const atprotoOAuthService = new AtprotoOAuthService();
export type { AtprotoSession };

View File

@@ -1,109 +0,0 @@
import axios from 'axios';
const API_BASE = '/api/v1';
interface LoginRequest {
identifier: string; // Handle or DID
password: string; // App password
}
interface LoginResponse {
access_token: string;
token_type: string;
did: string;
handle: string;
}
interface User {
did: string;
handle: string;
avatar?: string;
displayName?: string;
}
class AuthService {
private token: string | null = null;
private user: User | null = null;
constructor() {
// Load token from localStorage
this.token = localStorage.getItem('ai_card_token');
// Set default auth header if token exists
if (this.token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
}
}
async login(identifier: string, password: string): Promise<LoginResponse> {
try {
const response = await axios.post<LoginResponse>(`${API_BASE}/auth/login`, {
identifier,
password
});
const { access_token, did, handle } = response.data;
// Store token
this.token = access_token;
localStorage.setItem('ai_card_token', access_token);
// Set auth header
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
// Store user info
this.user = { did, handle };
return response.data;
} catch (error) {
throw new Error('Login failed');
}
}
async logout(): Promise<void> {
try {
await axios.post(`${API_BASE}/auth/logout`);
} catch (error) {
// Ignore errors
}
// Clear token
this.token = null;
this.user = null;
localStorage.removeItem('ai_card_token');
delete axios.defaults.headers.common['Authorization'];
}
async verify(): Promise<User | null> {
if (!this.token) {
return null;
}
try {
const response = await axios.get<User & { valid: boolean }>(`${API_BASE}/auth/verify`);
if (response.data.valid) {
this.user = {
did: response.data.did,
handle: response.data.handle
};
return this.user;
}
} catch (error) {
// Token is invalid
this.logout();
}
return null;
}
getUser(): User | null {
return this.user;
}
isAuthenticated(): boolean {
return this.token !== null;
}
}
export const authService = new AuthService();
export type { User, LoginRequest, LoginResponse };

View File

@@ -1,331 +0,0 @@
.card {
width: 250px;
height: 380px;
border-radius: 12px;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border: 2px solid #333;
overflow: hidden;
position: relative;
cursor: pointer;
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
}
.card-inner {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
/* Rarity effects */
.card-normal {
border-color: #666;
}
.card-rare {
border-color: #4a90e2;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.card-super-rare {
border-color: #9c27b0;
background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%);
}
.card-kira {
border-color: #ffd700;
background: linear-gradient(135deg, #232526 0%, #414345 100%);
position: relative;
}
.card-kira::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 215, 0, 0.1) 50%,
transparent 70%
);
animation: shimmer 3s infinite;
}
.card-unique {
border-color: #ff00ff;
background: linear-gradient(135deg, #000000 0%, #1a0033 100%);
box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
}
.card-unique::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
circle at center,
transparent 0%,
rgba(255, 0, 255, 0.2) 100%
);
animation: pulse 2s infinite;
}
/* Card content */
.card-header {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #888;
margin-bottom: 10px;
}
.card-image-container {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.card-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card-name {
font-size: 28px;
margin: 0;
color: var(--card-color, #fff);
text-align: center;
font-weight: bold;
}
.unique-badge {
margin-top: 10px;
padding: 5px 15px;
background: linear-gradient(90deg, #ff00ff, #00ffff);
border-radius: 20px;
font-size: 12px;
font-weight: bold;
animation: glow 2s ease-in-out infinite;
}
.card-skill {
margin-top: 20px;
padding: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 12px;
}
.card-footer {
text-align: center;
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Animations */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
@keyframes glow {
0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); }
100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
}
/* Simple Card Styles */
.card-simple {
width: 240px;
height: auto;
background: transparent;
border: none;
padding: 0;
}
.card-frame {
position: relative;
width: 100%;
aspect-ratio: 3/4;
border-radius: 8px;
overflow: hidden;
background: #1a1a1a;
padding: 25px 25px 30px 25px;
border: 3px solid #666;
box-sizing: border-box;
}
/* Normal card - no effects */
.card-simple.card-normal .card-frame {
border-color: #666;
background: #1a1a1a;
}
/* Unique (rare) card - glowing effects */
.card-simple.card-unique .card-frame {
border-color: #ffd700;
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
position: relative;
isolation: isolate;
overflow: hidden;
}
/* Particle/grainy texture for rare cards */
.card-simple.card-unique .card-frame::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px),
repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px);
background-size: 20px 20px, 30px 30px;
opacity: 0.8;
z-index: 1;
pointer-events: none;
}
/* Reflection effect for rare cards */
.card-simple.card-unique .card-frame::after {
content: "";
height: 100%;
width: 40px;
position: absolute;
top: -180px;
left: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 215, 0, 0.8) 20%,
rgba(255, 255, 0, 0.9) 40%,
rgba(255, 223, 0, 1) 50%,
rgba(255, 255, 0, 0.9) 60%,
rgba(255, 215, 0, 0.8) 80%,
transparent 100%
);
opacity: 0;
transform: rotate(45deg);
animation: gold-reflection 6s ease-in-out infinite;
z-index: 2;
}
@keyframes gold-reflection {
0% { transform: scale(0) rotate(45deg); opacity: 0; }
15% { transform: scale(0) rotate(45deg); opacity: 0; }
17% { transform: scale(4) rotate(45deg); opacity: 0.8; }
20% { transform: scale(50) rotate(45deg); opacity: 0; }
100% { transform: scale(50) rotate(45deg); opacity: 0; }
}
/* Glowing backlight effect */
.card-simple.card-unique {
position: relative;
}
.card-simple.card-unique::after {
position: absolute;
content: "";
top: 5px;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
height: 100%;
width: 100%;
margin: 0 auto;
transform: scale(0.95);
filter: blur(15px);
background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%);
opacity: 0.6;
}
/* Glowing border effect for rare cards */
.card-simple.card-unique .card-frame {
box-shadow:
0 0 10px rgba(255, 215, 0, 0.5),
inset 0 0 10px rgba(255, 215, 0, 0.1);
}
.card-image-simple {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
position: relative;
z-index: 1;
}
.card-cp-bar {
width: 100%;
height: 50px;
background: #333;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 12px;
margin-bottom: 8px;
border: 2px solid #666;
position: relative;
box-sizing: border-box;
overflow: hidden;
}
.card-simple.card-unique .card-cp-bar {
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
border-color: #ffd700;
box-shadow:
0 0 5px rgba(255, 215, 0, 0.3),
inset 0 0 5px rgba(255, 215, 0, 0.1);
}
.cp-value {
font-size: 20px;
font-weight: bold;
color: #fff;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
z-index: 1;
position: relative;
}

View File

@@ -1,196 +0,0 @@
.card-box-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.card-box-header h3 {
color: #495057;
margin: 0;
font-size: 24px;
}
.box-actions {
display: flex;
gap: 10px;
}
.uri-display {
background: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
}
.uri-display p {
margin: 0;
color: #1565c0;
font-size: 14px;
}
.uri-display code {
background: #ffffff;
border: 1px solid #90caf9;
border-radius: 4px;
padding: 4px 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #0d47a1;
word-break: break-all;
}
.json-button,
.refresh-button,
.retry-button,
.delete-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.json-button {
background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%);
color: white;
}
.json-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4);
}
.refresh-button {
background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
color: white;
}
.refresh-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4);
}
.retry-button {
background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
color: white;
}
.retry-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4);
}
.delete-button {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
}
.delete-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.json-display {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.json-display h4 {
color: #495057;
margin-top: 0;
margin-bottom: 15px;
}
.json-content {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #495057;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.box-stats {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.box-stats p {
margin: 0;
color: #495057;
font-size: 14px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.box-card-item {
text-align: center;
}
.card-info {
margin-top: 8px;
color: #6c757d;
font-size: 12px;
}
.empty-box {
text-align: center;
padding: 40px 20px;
color: #6c757d;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.empty-box p {
margin: 8px 0;
}
.loading,
.error {
text-align: center;
padding: 40px 20px;
color: #6c757d;
font-size: 16px;
}
.error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 8px;
}

View File

@@ -1,170 +0,0 @@
.card-list-container {
min-height: 100vh;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
padding: 20px;
}
.card-list-header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-list-header h1 {
color: #fff;
margin: 0 0 10px 0;
font-size: 2.5rem;
}
.card-list-header p {
color: #999;
margin: 0;
font-size: 1.1rem;
}
.card-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 30px;
max-width: 1400px;
margin: 0 auto;
}
.card-list-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
/* Simple grid layout for user-page style */
.card-list-simple-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.card-list-simple-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.info-button {
background: linear-gradient(135deg, #333 0%, #555 100%);
color: white;
border: 2px solid #666;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
width: 100%;
max-width: 240px;
}
.info-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: linear-gradient(135deg, #444 0%, #666 100%);
}
.card-info-details {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
width: 100%;
max-width: 240px;
margin-top: 10px;
}
.card-info-details p {
margin: 5px 0;
color: #ccc;
font-size: 0.85rem;
text-align: left;
}
.card-info-details p strong {
color: #fff;
}
.card-meta {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
width: 100%;
max-width: 250px;
}
.card-meta p {
margin: 5px 0;
color: #ccc;
font-size: 0.9rem;
}
.card-meta p:first-child {
font-weight: bold;
color: #fff;
}
.card-description {
font-size: 0.85rem;
color: #999;
font-style: italic;
margin-top: 8px;
line-height: 1.4;
}
.source-info {
font-size: 0.9rem;
color: #666;
margin-top: 5px;
}
.loading, .error {
text-align: center;
padding: 40px;
color: #999;
font-size: 1.2rem;
}
.error {
color: #ff4757;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
margin-top: 20px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
@media (max-width: 768px) {
.card-list-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.card-list-header h1 {
font-size: 2rem;
}
}

View File

@@ -1,172 +0,0 @@
.collection-analysis {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
color: white;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.collection-analysis h3 {
margin: 0 0 20px 0;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.analysis-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.8;
}
.rarity-distribution {
margin-bottom: 24px;
}
.rarity-distribution h4 {
margin: 0 0 16px 0;
font-size: 1.2rem;
font-weight: 500;
}
.rarity-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.rarity-bar {
display: flex;
align-items: center;
gap: 12px;
}
.rarity-name {
min-width: 80px;
font-weight: 500;
text-transform: capitalize;
}
.bar-container {
flex: 1;
height: 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
overflow: hidden;
}
.bar {
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
}
.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); }
.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); }
.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); }
.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); }
.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); }
.rarity-count {
min-width: 40px;
text-align: right;
font-weight: 500;
}
.recommendations {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.recommendations h4 {
margin: 0 0 12px 0;
font-size: 1.1rem;
}
.recommendations ul {
margin: 0;
padding-left: 20px;
}
.recommendations li {
margin-bottom: 8px;
line-height: 1.4;
}
.refresh-analysis,
.analyze-button,
.retry-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 8px;
padding: 12px 24px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: block;
margin: 0 auto;
}
.refresh-analysis:hover,
.analyze-button:hover,
.retry-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.analysis-loading,
.analysis-error,
.analysis-empty {
text-align: center;
padding: 40px 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.analysis-error p {
color: #ffcdd2;
margin-bottom: 16px;
}
.analysis-empty p {
opacity: 0.8;
margin-bottom: 16px;
}

View File

@@ -1,174 +0,0 @@
.gacha-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
cursor: pointer;
}
.card-final {
position: relative;
text-align: center;
}
.card-actions {
position: absolute;
bottom: -80px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.save-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 25px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.save-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.save-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.click-hint {
color: white;
font-size: 12px;
background: rgba(0, 0, 0, 0.7);
padding: 6px 12px;
border-radius: 15px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.gacha-opening {
position: relative;
}
.gacha-pack {
width: 200px;
height: 280px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
position: relative;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.pack-glow {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
animation: glow-pulse 2s ease-in-out infinite;
}
/* Effect variations */
.effect-normal {
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.effect-rare {
background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%);
}
.effect-kira {
background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%);
}
.effect-kira::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 60,40 100,50 60,60 50,100 40,60 0,50 40,40" fill="rgba(255,215,0,0.1)"/></svg>');
background-size: 50px 50px;
animation: sparkle 3s linear infinite;
}
.effect-unique {
background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%);
overflow: hidden;
}
.unique-effect {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.unique-particles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle, #ff00ff 1px, transparent 1px),
radial-gradient(circle, #00ffff 1px, transparent 1px);
background-size: 50px 50px, 30px 30px;
background-position: 0 0, 25px 25px;
animation: particle-float 20s linear infinite;
}
.unique-burst {
position: absolute;
top: 50%;
left: 50%;
width: 300px;
height: 300px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%);
animation: burst 1s ease-out;
}
/* Animations */
@keyframes glow-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes sparkle {
0% { transform: translateY(0) rotate(0deg); }
100% { transform: translateY(-100vh) rotate(360deg); }
}
@keyframes particle-float {
0% { transform: translate(0, 0); }
100% { transform: translate(-50px, -100px); }
}
@keyframes burst {
0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
}

View File

@@ -1,219 +0,0 @@
.gacha-stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
color: white;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.gacha-stats h3 {
margin: 0 0 20px 0;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.stats-overview {
margin-bottom: 24px;
text-align: center;
}
.overview-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
display: inline-block;
min-width: 200px;
}
.overview-value {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 8px;
}
.overview-label {
font-size: 1rem;
opacity: 0.9;
}
.rarity-stats {
margin-bottom: 24px;
}
.rarity-stats h4 {
margin: 0 0 16px 0;
font-size: 1.2rem;
font-weight: 500;
}
.rarity-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.rarity-stat {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
.rarity-stat::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--rarity-color);
}
.rarity-stat.rarity-common { --rarity-color: #4CAF50; }
.rarity-stat.rarity-rare { --rarity-color: #2196F3; }
.rarity-stat.rarity-epic { --rarity-color: #9C27B0; }
.rarity-stat.rarity-legendary { --rarity-color: #FF9800; }
.rarity-stat.rarity-mythic { --rarity-color: #F44336; }
.rarity-count {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 4px;
}
.rarity-name {
font-size: 0.9rem;
opacity: 0.9;
text-transform: capitalize;
margin-bottom: 4px;
}
.success-rate {
font-size: 0.8rem;
opacity: 0.7;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 2px 6px;
display: inline-block;
}
.recent-activity {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.recent-activity h4 {
margin: 0 0 12px 0;
font-size: 1.1rem;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.activity-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.activity-time {
font-size: 0.8rem;
opacity: 0.7;
min-width: 120px;
}
.activity-details {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: flex-end;
}
.card-rarity {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.card-rarity.rarity-common { background: #4CAF50; }
.card-rarity.rarity-rare { background: #2196F3; }
.card-rarity.rarity-epic { background: #9C27B0; }
.card-rarity.rarity-legendary { background: #FF9800; }
.card-rarity.rarity-mythic { background: #F44336; }
.card-name {
font-weight: 500;
}
.refresh-stats,
.load-stats-button,
.retry-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 8px;
padding: 12px 24px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: block;
margin: 0 auto;
}
.refresh-stats:hover,
.load-stats-button:hover,
.retry-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.stats-loading,
.stats-error,
.stats-empty {
text-align: center;
padding: 40px 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stats-error p {
color: #ffcdd2;
margin-bottom: 16px;
}
.stats-empty p {
opacity: 0.8;
margin-bottom: 16px;
}

View File

@@ -1,243 +0,0 @@
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.login-modal {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border: 1px solid #444;
border-radius: 16px;
padding: 40px;
max-width: 450px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.login-mode-selector {
display: flex;
margin-bottom: 24px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 4px;
}
.mode-button {
flex: 1;
padding: 12px 16px;
border: none;
background: transparent;
color: #ccc;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.mode-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.mode-button:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.oauth-login {
text-align: center;
}
.oauth-info {
margin-bottom: 24px;
padding: 20px;
background: rgba(102, 126, 234, 0.1);
border-radius: 12px;
border: 1px solid rgba(102, 126, 234, 0.3);
}
.oauth-info h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #667eea;
}
.oauth-info p {
margin: 0;
font-size: 14px;
line-height: 1.5;
opacity: 0.9;
}
.oauth-login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 16px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
}
.oauth-login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.oauth-login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.login-modal h2 {
margin: 0 0 30px 0;
font-size: 28px;
text-align: center;
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #ccc;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #444;
border-radius: 8px;
color: white;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #fff700;
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2);
}
.form-group input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group small {
display: block;
margin-top: 6px;
color: #888;
font-size: 12px;
}
.form-group small a {
color: #fff700;
text-decoration: none;
}
.form-group small a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(255, 71, 87, 0.1);
border: 1px solid rgba(255, 71, 87, 0.3);
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
color: #ff4757;
font-size: 14px;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 30px;
}
.login-button,
.cancel-button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.login-button {
background: linear-gradient(135deg, #fff700 0%, #ffd700 100%);
color: #000;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4);
}
.login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cancel-button {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid #444;
}
.cancel-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
border-color: #666;
}
.login-info {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #333;
text-align: center;
}
.login-info p {
color: #888;
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.dev-notice {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 6px;
padding: 8px 12px;
margin: 10px 0;
color: #ffc107;
font-size: 12px;
text-align: center;
}

View File

@@ -1,24 +0,0 @@
export enum CardRarity {
NORMAL = "normal",
RARE = "rare",
SUPER_RARE = "super_rare",
KIRA = "kira",
UNIQUE = "unique"
}
export interface Card {
id: number;
cp: number;
status: CardRarity;
skill?: string;
owner_did: string;
obtained_at: string;
is_unique: boolean;
unique_id?: string;
}
export interface CardDrawResult {
card: Card;
is_new: boolean;
animation_type: string;
}

View File

@@ -1,141 +0,0 @@
/**
* OAuth dynamic endpoint handlers
*/
import { OAuthKeyManager, generateClientMetadata } from './oauth-keys';
export class OAuthEndpointHandler {
/**
* Initialize OAuth endpoint handlers
*/
static init() {
// Intercept requests to client-metadata.json
this.setupClientMetadataHandler();
// Intercept requests to .well-known/jwks.json
this.setupJWKSHandler();
}
private static setupClientMetadataHandler() {
// Override fetch for client-metadata.json requests
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
// Only intercept local OAuth endpoints
try {
const urlObj = new URL(url, window.location.origin);
// Only intercept requests to the same origin
if (urlObj.origin !== window.location.origin) {
// Pass through external API calls unchanged
return originalFetch(input, init);
}
// Handle local OAuth endpoints
if (urlObj.pathname.endsWith('/client-metadata.json')) {
const metadata = generateClientMetadata();
return new Response(JSON.stringify(metadata, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
if (urlObj.pathname.endsWith('/.well-known/jwks.json')) {
try {
const jwks = await OAuthKeyManager.getJWKS();
return new Response(JSON.stringify(jwks, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
console.error('Failed to generate JWKS:', error);
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
} catch (e) {
// If URL parsing fails, pass through to original fetch
console.debug('URL parsing failed, passing through:', e);
}
// Pass through all other requests
return originalFetch(input, init);
};
}
private static setupJWKSHandler() {
// This is handled in the fetch override above
}
/**
* Generate a proper client assertion JWT for token requests
*/
static async generateClientAssertion(tokenEndpoint: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const clientId = generateClientMetadata().client_id;
const header = {
alg: 'ES256',
typ: 'JWT',
kid: 'ai-card-oauth-key-1'
};
const payload = {
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
iat: now,
exp: now + 300, // 5 minutes
jti: crypto.randomUUID()
};
return await OAuthKeyManager.signJWT(header, payload);
}
}
/**
* Service Worker alternative for intercepting requests
* (This is a more robust solution for production)
*/
export function registerOAuthServiceWorker() {
if ('serviceWorker' in navigator) {
const swCode = `
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.endsWith('/client-metadata.json')) {
event.respondWith(
new Response(JSON.stringify({
client_id: url.origin + '/client-metadata.json',
client_name: 'ai.card',
client_uri: url.origin,
redirect_uris: [url.origin + '/oauth/callback'],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: url.origin + '/.well-known/jwks.json'
}, null, 2), {
headers: { 'Content-Type': 'application/json' }
})
);
}
});
`;
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(swUrl).catch(console.error);
}
}

View File

@@ -1,183 +0,0 @@
/**
* OAuth JWKS key generation and management
*/
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
d?: string;
use: string;
kid: string;
alg: string;
}
export interface JWKS {
keys: JWK[];
}
export class OAuthKeyManager {
private static keyPair: CryptoKeyPair | null = null;
private static jwks: JWKS | null = null;
/**
* Generate or retrieve existing ECDSA key pair for OAuth
*/
static async getKeyPair(): Promise<CryptoKeyPair> {
if (this.keyPair) {
return this.keyPair;
}
// Try to load from localStorage first
const storedKey = localStorage.getItem('oauth_private_key');
if (storedKey) {
try {
const keyData = JSON.parse(storedKey);
this.keyPair = await this.importKeyPair(keyData);
return this.keyPair;
} catch (error) {
console.warn('Failed to load stored key, generating new one:', error);
localStorage.removeItem('oauth_private_key');
}
}
// Generate new key pair
this.keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true, // extractable
['sign', 'verify']
);
// Store private key for persistence
await this.storeKeyPair(this.keyPair);
return this.keyPair;
}
/**
* Get JWKS (JSON Web Key Set) for public key distribution
*/
static async getJWKS(): Promise<JWKS> {
if (this.jwks) {
return this.jwks;
}
const keyPair = await this.getKeyPair();
const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
this.jwks = {
keys: [
{
kty: publicKey.kty!,
crv: publicKey.crv!,
x: publicKey.x!,
y: publicKey.y!,
use: 'sig',
kid: 'ai-card-oauth-key-1',
alg: 'ES256'
}
]
};
return this.jwks;
}
/**
* Sign a JWT with the private key
*/
static async signJWT(header: any, payload: any): Promise<string> {
const keyPair = await this.getKeyPair();
const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
const message = `${headerB64}.${payloadB64}`;
const signature = await window.crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(message)
);
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `${message}.${signatureB64}`;
}
private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> {
try {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
console.error('Failed to store private key:', error);
}
}
private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
const privateKey = await window.crypto.subtle.importKey(
'jwk',
keyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign']
);
// Derive public key from private key
const publicKeyData = { ...keyData };
delete publicKeyData.d; // Remove private component
const publicKey = await window.crypto.subtle.importKey(
'jwk',
publicKeyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify']
);
return { privateKey, publicKey };
}
/**
* Clear stored keys (for testing/reset)
*/
static clearKeys(): void {
localStorage.removeItem('oauth_private_key');
this.keyPair = null;
this.jwks = null;
}
}
/**
* Generate dynamic client metadata based on current URL
*/
export function generateClientMetadata(): any {
// Use environment variables if available, fallback to current origin
const host = import.meta.env.VITE_APP_HOST || window.location.origin;
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
return {
client_id: clientId,
client_name: 'ai.card',
client_uri: host,
logo_uri: `${host}/favicon.ico`,
tos_uri: `${host}/terms`,
policy_uri: `${host}/privacy`,
redirect_uris: [redirectUri, host],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
token_endpoint_auth_signing_alg: 'ES256',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: `${host}/.well-known/jwks.json`
};
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

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

View File

@@ -1,88 +0,0 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import fs from 'fs'
import path from 'path'
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
react(),
// Custom plugin to replace variables in public files during build
{
name: 'replace-env-vars',
writeBundle() {
const host = env.VITE_APP_HOST || 'https://log.syui.ai'
const clientId = env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`
const redirectUri = env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`
// Replace variables in client-metadata.json
const clientMetadataPath = path.resolve(__dirname, 'dist/client-metadata.json')
if (fs.existsSync(clientMetadataPath)) {
let content = fs.readFileSync(clientMetadataPath, 'utf-8')
content = content.replace(/https:\/\/log\.syui\.ai/g, host)
fs.writeFileSync(clientMetadataPath, content)
console.log(`Updated client-metadata.json with host: ${host}`)
}
}
},
// Generate standalone index.html for testing
{
name: 'generate-standalone-html',
writeBundle(options, bundle) {
// Find actual generated filenames
const jsFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.js'))
const cssFile = Object.keys(bundle).find(fileName => fileName.startsWith('assets/comment-atproto') && fileName.endsWith('.css'))
// Generate minimal index.html with just asset references
const indexHtmlPath = path.resolve(__dirname, 'dist/index.html')
const indexHtmlContent = `<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/${jsFile}"></script>
<link rel="stylesheet" crossorigin href="/${cssFile}">`
fs.writeFileSync(indexHtmlPath, indexHtmlContent)
console.log('Generated minimal index.html with asset references')
}
}
],
build: {
// Keep console.log in production for debugging
minify: 'esbuild',
rollupOptions: {
output: {
// Hash-based filenames to bust cache
entryFileNames: 'assets/comment-atproto-[hash].js',
chunkFileNames: 'assets/comment-atproto-[name]-[hash].js',
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return 'assets/comment-atproto-[hash].css';
}
return 'assets/[name]-[hash].[ext]';
}
}
}
},
esbuild: {
drop: [], // Don't drop console.log
},
server: {
port: 5173,
host: '127.0.0.1',
allowedHosts: ['localhost', '127.0.0.1', 'log.syui.ai'],
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
}
},
// Handle OAuth callback routing
historyApiFallback: {
rewrites: [
{ from: /^\/oauth\/callback/, to: '/index.html' }
]
}
}
}
})

12
pds/index.html Normal file
View File

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

27
pds/package.json Normal file
View File

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

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